add teal as module

This commit is contained in:
Cayo Puigdefabregas 2023-03-21 12:08:13 +01:00
parent e624ab7a7a
commit 01ef359bd4
76 changed files with 7688 additions and 631 deletions

View file

@ -1,9 +1,9 @@
from sqlalchemy.exc import DataError
from teal.auth import TokenAuth
from teal.db import ResourceNotFound
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):

View file

@ -4,11 +4,11 @@ from typing import Dict, Iterable, Type, Union
from ereuse_utils.test import JSON, Res
from flask.testing import FlaskClient
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 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]

View file

@ -2,10 +2,6 @@ from distutils.version import StrictVersion
from itertools import chain
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 (
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.tradedocument import definitions as tradedocument
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):

View file

@ -4,7 +4,8 @@ from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import sessionmaker
from sqlalchemy.sql import expression
from sqlalchemy_utils import view
from teal.db import SchemaSQLAlchemy, SchemaSession
from ereuse_devicehub.teal.db import SchemaSession, SchemaSQLAlchemy
class DhSession(SchemaSession):
@ -23,6 +24,7 @@ class DhSession(SchemaSession):
# flush, all the new / dirty interesting things in a variable
# until DeviceSearch is executed
from ereuse_devicehub.resources.device.search import DeviceSearch
DeviceSearch.update_modified_devices(session=self)
@ -31,6 +33,7 @@ class SQLAlchemy(SchemaSQLAlchemy):
schema of the database, as it is in the `search_path`
defined in teal.
"""
# todo add here all types of columns used so we don't have to
# manually import them all the time
UUID = postgresql.UUID
@ -60,7 +63,9 @@ def create_view(name, selectable):
# We need to ensure views are created / destroyed before / after
# SchemaSQLAlchemy's listeners execute
# 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))
return table

View file

@ -10,8 +10,6 @@ from ereuse_utils.session import DevicehubClient
from flask import _app_ctx_stack, g
from flask_login import LoginManager, current_user
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.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.inventory import Inventory, InventoryDef
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

View file

@ -5,11 +5,11 @@ from flask import g
from sqlalchemy import Column, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import backref, relationship
from teal.db import CASCADE_OWN, URL
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
class Transfer(Thing):

View file

@ -1,14 +1,33 @@
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.teal.db import SQLAlchemy
from ereuse_devicehub.teal.marshmallow import NestedOn as TealNestedOn
class NestedOn(TealNestedOn):
__doc__ = TealNestedOn.__doc__
def __init__(self, nested, 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)
def __init__(
self,
nested,
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,
)

View file

@ -1,12 +1,12 @@
from typing import Dict, List
from flask import Response, jsonify, request
from teal.query import NestedQueryFlaskParser
from webargs.flaskparser import FlaskParser
from ereuse_devicehub.teal.query import NestedQueryFlaskParser
class SearchQueryParser(NestedQueryFlaskParser):
def parse_querystring(self, req, name, field):
if name == 'search':
v = FlaskParser.parse_querystring(self, req, name, field)
@ -15,18 +15,21 @@ class SearchQueryParser(NestedQueryFlaskParser):
return v
def things_response(items: List[Dict],
def things_response(
items: List[Dict],
page: int = None,
per_page: int = None,
total: int = None,
previous: int = None,
next: int = None,
url: str = None,
code: int = 200) -> Response:
code: int = 200,
) -> Response:
"""Generates a Devicehub API list conformant response for multiple
things.
"""
response = jsonify({
response = jsonify(
{
'items': items,
# todo pagination should be in Header like github
# https://developer.github.com/v3/guides/traversing-with-pagination/
@ -35,9 +38,10 @@ def things_response(items: List[Dict],
'perPage': per_page,
'total': total,
'previous': previous,
'next': next
'next': next,
},
'url': url or request.path
})
'url': url or request.path,
}
)
response.status_code = code
return response

View file

@ -1,11 +1,14 @@
from typing import Callable, Iterable, Tuple
from teal.resource import Converters, Resource
from ereuse_devicehub.resources.action import schemas
from ereuse_devicehub.resources.action.views.views import (ActionView, AllocateView, DeallocateView,
LiveView)
from ereuse_devicehub.resources.action.views.views import (
ActionView,
AllocateView,
DeallocateView,
LiveView,
)
from ereuse_devicehub.resources.device.sync import Sync
from ereuse_devicehub.teal.resource import Converters, Resource
class ActionDef(Resource):
@ -169,13 +172,32 @@ class SnapshotDef(ActionDef):
VIEW = None
SCHEMA = schemas.Snapshot
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
def __init__(
self,
app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None, cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
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)
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,
)
self.sync = Sync()

View file

@ -19,7 +19,6 @@ from typing import Optional, Set, Union
from uuid import uuid4
import inflection
import teal.db
from boltons import urlutils
from citext import CIText
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.events import AttributeEvents as Events
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.resources.agent.models import Agent
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.tradedocument.models import TradeDocument
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:
@ -119,7 +119,11 @@ class Action(Thing):
name.comment = """A name or title for the action. Used when searching
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__
closed = Column(Boolean, default=True, nullable=False)
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)
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.comment = Action.start_time.comment
end_time = Column(

View file

@ -21,9 +21,6 @@ from marshmallow.fields import (
)
from marshmallow.validate import Length, OneOf, Range
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.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.user import schemas as s_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):

View file

@ -1,5 +1,4 @@
from flask import g
from teal.marshmallow import ValidationError
from ereuse_devicehub.db import db
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.user.models import User
from ereuse_devicehub.teal.marshmallow import ValidationError
class TradeView:

View file

@ -8,9 +8,6 @@ import ereuse_utils
import jwt
from flask import current_app as app
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.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.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')

View file

@ -2,10 +2,10 @@ import json
import click
from boltons.typeutils import classproperty
from teal.resource import Converters, Resource
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.agent import models, schemas
from ereuse_devicehub.teal.resource import Converters, Resource
class AgentDef(Resource):
@ -19,26 +19,40 @@ class OrganizationDef(AgentDef):
SCHEMA = schemas.Organization
VIEW = None
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
def __init__(
self,
app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None):
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
):
cli_commands = ((self.create_org, 'add'),)
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
super().__init__(
app,
import_name,
static_folder,
static_url_path,
template_folder,
url_prefix,
subdomain,
url_defaults,
root_path,
cli_commands,
)
@click.argument('name')
@click.option('--tax_id', '-t')
@click.option('--country', '-c')
def create_org(self, name: str, tax_id: str = None, country: str = None) -> dict:
"""Creates an organization."""
org = models.Organization(**self.schema.load(
{
'name': name,
'taxId': tax_id,
'country': country
}
))
org = models.Organization(
**self.schema.load({'name': name, 'taxId': tax_id, 'country': country})
)
db.session.add(org)
db.session.commit()
o = self.schema.dump(org)

View file

@ -10,14 +10,19 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import backref, relationship, validates
from sqlalchemy_utils import EmailType, PhoneNumberType
from teal import enums
from teal.db import INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON, check_lower
from teal.marshmallow import ValidationError
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.inventory import Inventory
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
from ereuse_devicehub.resources.user.models import User
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:

View file

@ -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 teal import enums
from teal.marshmallow import EnumField, Phone, SanitizedStr
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.models import STR_SIZE, STR_SM_SIZE
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):
id = ma_fields.UUID(dump_only=True)
name = SanitizedStr(validate=ma_validate.Length(max=STR_SM_SIZE))
tax_id = SanitizedStr(lower=True,
validate=ma_validate.Length(max=STR_SM_SIZE),
data_key='taxId')
tax_id = SanitizedStr(
lower=True, validate=ma_validate.Length(max=STR_SM_SIZE), data_key='taxId'
)
country = EnumField(enums.Country)
telephone = Phone()
email = Email()

View file

@ -1,9 +1,8 @@
from typing import Callable, Iterable, Tuple
from teal.resource import Converters, Resource
from ereuse_devicehub.resources.deliverynote import schemas
from ereuse_devicehub.resources.deliverynote.views import DeliverynoteView
from ereuse_devicehub.teal.resource import Converters, Resource
class DeliverynoteDef(Resource):
@ -12,7 +11,9 @@ class DeliverynoteDef(Resource):
AUTH = True
ID_CONVERTER = Converters.uuid
def __init__(self, app,
def __init__(
self,
app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
@ -21,6 +22,17 @@ class DeliverynoteDef(Resource):
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)
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,
)

View file

@ -5,35 +5,47 @@ from typing import Iterable
from boltons import urlutils
from citext import CIText
from flask import g
from sqlalchemy.dialects.postgresql import UUID, JSONB
from teal.db import check_range, IntEnum
from teal.resource import url_for_resource
from sqlalchemy.dialects.postgresql import JSONB, UUID
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.enums import TransferState
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.resources.models import Thing
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):
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)
creator_id = db.Column(UUID(as_uuid=True),
creator_id = db.Column(
UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id)
default=lambda: g.user.id,
)
creator = db.relationship(User, primaryjoin=creator_id == User.id)
supplier_email = db.Column(CIText(),
supplier_email = db.Column(
CIText(),
db.ForeignKey(User.email),
nullable=False,
default=lambda: g.user.email)
supplier = db.relationship(User, primaryjoin=lambda: Deliverynote.supplier_email == User.email)
receiver_address = db.Column(CIText(),
default=lambda: g.user.email,
)
supplier = db.relationship(
User, primaryjoin=lambda: Deliverynote.supplier_email == 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)
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.comment = 'The date the DeliveryNote initiated'
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(db.ARRAY(JSONB, dimensions=1), nullable=False)
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__
lot_id = db.Column(UUID(as_uuid=True),
db.ForeignKey(Lot.id),
nullable=False)
lot = db.relationship(Lot,
lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
lot = db.relationship(
Lot,
backref=db.backref('deliverynote', uselist=False, 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__(
self,
document_id: str,
amount: str,
date,
supplier_email: str,
expected_devices: Iterable,
transfer_state: TransferState) -> None:
"""Initializes a delivery note
"""
super().__init__(id=uuid.uuid4(),
document_id=document_id, amount=amount, date=date,
transfer_state: TransferState,
) -> None:
"""Initializes a delivery note"""
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)
transfer_state=transfer_state,
)
@property
def type(self) -> str:

View file

@ -1,5 +1,4 @@
from marshmallow import fields as f
from teal.marshmallow import SanitizedStr, EnumField
from ereuse_devicehub.marshmallow import NestedOn
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.schemas import Thing
from ereuse_devicehub.resources.user import schemas as s_user
from ereuse_devicehub.teal.marshmallow import EnumField, SanitizedStr
class Deliverynote(Thing):
id = f.UUID(dump_only=True)
document_id = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
required=True, data_key='documentID')
document_id = SanitizedStr(
validate=f.validate.Length(max=STR_SIZE), required=True, data_key='documentID'
)
creator = NestedOn(s_user.User, dump_only=True)
supplier_email = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
load_only=True, required=True, data_key='supplierEmail')
supplier_email = SanitizedStr(
validate=f.validate.Length(max=STR_SIZE),
load_only=True,
required=True,
data_key='supplierEmail',
)
supplier = NestedOn(s_user.User, dump_only=True)
receiver = NestedOn(s_user.User, dump_only=True)
date = f.DateTime('iso', required=True)
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
description=m.Deliverynote.amount.__doc__)
amount = f.Integer(
validate=f.validate.Range(min=0, max=100),
description=m.Deliverynote.amount.__doc__,
)
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)

View file

@ -2,21 +2,22 @@ import datetime
import uuid
from flask import Response, request
from teal.resource import View
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.deliverynote.models import Deliverynote
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.teal.resource import View
class DeliverynoteView(View):
def post(self):
# Create delivery note
dn = request.get_json()
dlvnote = Deliverynote(**dn)
# 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)
dlvnote.lot_id = new_lot.id
db.session.add(new_lot)

View file

@ -1,7 +1,5 @@
from typing import Callable, Iterable, Tuple
from teal.resource import Converters, Resource
from ereuse_devicehub.resources.device import schemas
from ereuse_devicehub.resources.device.models import Manufacturer
from ereuse_devicehub.resources.device.views import (
@ -9,6 +7,7 @@ from ereuse_devicehub.resources.device.views import (
DeviceView,
ManufacturerView,
)
from ereuse_devicehub.teal.resource import Converters, Resource
class DeviceDef(Resource):

View file

@ -1,10 +1,11 @@
from teal.marshmallow import ValidationError
from ereuse_devicehub.teal.marshmallow import ValidationError
class MismatchBetweenIds(ValidationError):
def __init__(self, other_device_id: int, field: str, value: str):
message = 'The device {} has the same {} than this one ({}).'.format(other_device_id,
field, value)
message = 'The device {} has the same {} than this one ({}).'.format(
other_device_id, field, value
)
super().__init__(message, field_names=[field])
@ -15,13 +16,15 @@ class NeedsId(ValidationError):
class DeviceIsInAnotherDevicehub(ValidationError):
def __init__(self,
def __init__(
self,
tag_id,
message=None,
field_names=None,
fields=None,
data=None,
valid_data=None,
**kwargs):
**kwargs,
):
message = message or 'Device {} is from another Devicehub.'.format(tag_id)
super().__init__(message, field_names, fields, data, valid_data, **kwargs)

View file

@ -35,19 +35,6 @@ from sqlalchemy.orm import ColumnProperty, backref, relationship, validates
from sqlalchemy.util import OrderedSet
from sqlalchemy_utils import ColorType
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.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.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):

View file

@ -17,9 +17,6 @@ from marshmallow.fields import (
from marshmallow.validate import Length, OneOf, Range
from sqlalchemy.util import OrderedSet
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.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.models import STR_BIG_SIZE, STR_SIZE
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):

View file

@ -8,8 +8,6 @@ from flask import g
from sqlalchemy import inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.util import OrderedSet
from teal.db import ResourceNotFound
from teal.marshmallow import ValidationError
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.action.models import Remove
@ -21,6 +19,8 @@ from ereuse_devicehub.resources.device.models import (
Placeholder,
)
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 = [
# 'RamModule',

View file

@ -14,11 +14,6 @@ from marshmallow import fields
from marshmallow import fields as f
from marshmallow import validate as v
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.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.lot.models import LotDeviceDescendants
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):

View file

@ -11,14 +11,12 @@ from typing import Callable, Iterable, Tuple
import boltons
import flask
import flask_weasyprint
import teal.marshmallow
from boltons import urlutils
from flask import current_app as app
from flask import g, make_response, request
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.db import db
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.models import Lot
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):
@ -46,7 +46,7 @@ class Format(enum.Enum):
class DocumentView(DeviceView):
class FindArgs(DeviceView.FindArgs):
format = teal.marshmallow.EnumField(Format, missing=None)
format = ereuse_devicehub.teal.marshmallow.EnumField(Format, missing=None)
def get(self, id):
"""Get a collection of resources or a specific one.
@ -71,7 +71,7 @@ class DocumentView(DeviceView):
if not ids and not id:
msg = 'Document must be an ID or UUID.'
raise teal.marshmallow.ValidationError(msg)
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
if id:
try:
@ -81,7 +81,7 @@ class DocumentView(DeviceView):
ids.append(int(id))
except ValueError:
msg = 'Document must be an ID or UUID.'
raise teal.marshmallow.ValidationError(msg)
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
else:
query = devs.Device.query.filter(Device.id.in_(ids))
else:
@ -98,7 +98,7 @@ class DocumentView(DeviceView):
# try:
# id = int(id)
# 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:
# query = devs.Device.query.filter_by(id=id)
# else:

View file

@ -1,20 +1,19 @@
from flask import g
from citext import CIText
from flask import g
from sortedcontainers import SortedSet
from sqlalchemy import BigInteger, Column, Sequence, Unicode, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Sequence, Unicode
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import backref
from teal.db import CASCADE_OWN, URL
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.models import Thing, STR_SM_SIZE
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
_sorted_documents = {
'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
"""
id_document = Column(CIText(), nullable=True)
id_document.comment = """The id of one document like invoice so they can be linked."""
owner_id = db.Column(UUID(as_uuid=True),
id_document.comment = (
"""The id of one document like invoice so they can be linked."""
)
owner_id = db.Column(
UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id)
default=lambda: g.user.id,
)
owner = db.relationship(User, primaryjoin=owner_id == User.id)
file_name = Column(db.CIText(), nullable=False)
file_name.comment = """This is the name of the file when user up the document."""

View file

@ -1,34 +1,43 @@
from marshmallow.fields import DateTime, Integer, validate, Boolean, Float
from marshmallow import post_load
from marshmallow.fields import Boolean, DateTime, Float, Integer, validate
from marshmallow.validate import Range
from teal.marshmallow import SanitizedStr, URL
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.tradedocument.models import TradeDocument
from ereuse_devicehub.resources.documents import models as m
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
class DataWipeDocument(Thing):
__doc__ = m.DataWipeDocument.__doc__
id = Integer(description=m.DataWipeDocument.id.comment, dump_only=True)
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)
date = DateTime(data_key='endTime',
required=False,
description=m.DataWipeDocument.date.comment)
id_document = SanitizedStr(data_key='documentId',
date = DateTime(
data_key='endTime', required=False, description=m.DataWipeDocument.date.comment
)
id_document = SanitizedStr(
data_key='documentId',
required=False,
default='',
description=m.DataWipeDocument.id_document.comment)
file_name = SanitizedStr(data_key='filename',
description=m.DataWipeDocument.id_document.comment,
)
file_name = SanitizedStr(
data_key='filename',
default='',
description=m.DataWipeDocument.file_name.comment,
validate=validate.Length(max=100))
file_hash = SanitizedStr(data_key='hash',
validate=validate.Length(max=100),
)
file_hash = SanitizedStr(
data_key='hash',
default='',
description=m.DataWipeDocument.file_hash.comment,
validate=validate.Length(max=64))
validate=validate.Length(max=64),
)
@post_load
def get_trade_document(self, data):

View file

@ -1,28 +1,34 @@
from uuid import uuid4
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.orm import backref, relationship
from sqlalchemy.util import OrderedSet
from teal.db import CASCADE_OWN
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.resources.enums import ImageMimeTypes, Orientation
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.teal.db import CASCADE_OWN
class ImageList(Thing):
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
device_id = Column(BigInteger, ForeignKey(Device.id), nullable=False)
device = relationship(Device,
device = relationship(
Device,
primaryjoin=Device.id == device_id,
backref=backref('images',
backref=backref(
'images',
lazy=True,
cascade=CASCADE_OWN,
order_by=lambda: ImageList.created,
collection_class=OrderedSet))
collection_class=OrderedSet,
),
)
class Image(Thing):
@ -32,12 +38,16 @@ class Image(Thing):
file_format = db.Column(DBEnum(ImageMimeTypes), nullable=False)
orientation = db.Column(DBEnum(Orientation), nullable=False)
image_list_id = Column(UUID(as_uuid=True), ForeignKey(ImageList.id), nullable=False)
image_list = relationship(ImageList,
image_list = relationship(
ImageList,
primaryjoin=ImageList.id == image_list_id,
backref=backref('images',
backref=backref(
'images',
cascade=CASCADE_OWN,
order_by=lambda: Image.created,
collection_class=OrderedSet))
collection_class=OrderedSet,
),
)
# todo make an image Field that converts to/from image object
# todo which metadata we get from Photobox?

View file

@ -2,42 +2,61 @@ import uuid
import boltons.urlutils
from flask import current_app
from teal.db import ResourceNotFound
from teal.resource import Resource
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.inventory import schema
from ereuse_devicehub.resources.inventory.model import Inventory
from ereuse_devicehub.teal.db import ResourceNotFound
from ereuse_devicehub.teal.resource import Resource
class InventoryDef(Resource):
SCHEMA = schema.Inventory
VIEW = None
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
def __init__(
self,
app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None):
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path)
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
def set_inventory_config(cls,
def set_inventory_config(
cls,
name: str = None,
org_name: str = None,
org_id: str = None,
tag_url: boltons.urlutils.URL = None,
tag_token: uuid.UUID = None):
tag_token: uuid.UUID = None,
):
try:
inventory = Inventory.current
except ResourceNotFound: # No inventory defined in db yet
inventory = Inventory(id=current_app.id,
name=name,
tag_provider=tag_url,
tag_token=tag_token)
inventory = Inventory(
id=current_app.id, name=name, tag_provider=tag_url, tag_token=tag_token
)
db.session.add(inventory)
if org_name or org_id:
from ereuse_devicehub.resources.agent.models import Organization
try:
org = Organization.query.filter_by(tax_id=org_id, name=org_name).one()
except ResourceNotFound:
@ -54,12 +73,14 @@ class InventoryDef(Resource):
only access to this inventory.
"""
from ereuse_devicehub.resources.user.models import User, UserInventory
inv = Inventory.query.filter_by(id=current_app.id).one()
db.session.delete(inv)
db.session.flush()
# Remove users that end-up without any inventory
# todo this should be done in a trigger / action
users = User.query \
.filter(User.id.notin_(db.session.query(UserInventory.user_id).distinct()))
users = User.query.filter(
User.id.notin_(db.session.query(UserInventory.user_id).distinct())
)
for user in users:
db.session.delete(user)

View file

@ -1,6 +1,8 @@
from typing import Callable, Iterable, Tuple
from flask.json import jsonify
from teal.resource import Resource, View
from ereuse_devicehub.teal.resource import Resource, View
class LicenceView(View):
@ -23,7 +25,9 @@ class LicencesDef(Resource):
VIEW = None # We do not want to create default / documents endpoint
AUTH = False
def __init__(self, app,
def __init__(
self,
app,
import_name=__name__,
static_folder=None,
static_url_path=None,
@ -32,9 +36,20 @@ class LicencesDef(Resource):
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)
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'}
d = {}

View file

@ -1,12 +1,15 @@
import pathlib
from typing import Callable, Iterable, Tuple
from teal.resource import Converters, Resource
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.lot import schemas
from ereuse_devicehub.resources.lot.views import LotBaseChildrenView, LotChildrenView, \
LotDeviceView, LotView
from ereuse_devicehub.resources.lot.views import (
LotBaseChildrenView,
LotChildrenView,
LotDeviceView,
LotView,
)
from ereuse_devicehub.teal.resource import Converters, Resource
class LotDef(Resource):
@ -15,24 +18,49 @@ class LotDef(Resource):
AUTH = True
ID_CONVERTER = Converters.uuid
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
def __init__(
self,
app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None, cli_commands: 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)
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:
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(
'/<{}:{}>/children'.format(self.ID_CONVERTER.value, self.ID_NAME),
view_func=lot_children,
methods={'POST', 'DELETE'})
methods={'POST', 'DELETE'},
)
lot_device = LotDeviceView.as_view('lot-device', definition=self, auth=app.auth)
if self.AUTH:
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(
'/<{}:{}>/devices'.format(self.ID_CONVERTER.value, self.ID_NAME),
view_func=lot_device,
methods={'POST', 'DELETE'})
methods={'POST', 'DELETE'},
)
def init_db(self, db: 'db.SQLAlchemy', exclude_schema=None):
# Create functions

View file

@ -10,14 +10,14 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy_utils import LtreeType
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.resources.device.models import Component, Device
from ereuse_devicehub.resources.enums import TransferState
from ereuse_devicehub.resources.models import Thing
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):

View file

@ -1,15 +1,14 @@
from marshmallow import fields as f
from teal.marshmallow import SanitizedStr, URL, EnumField
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.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.lot import models as m
from ereuse_devicehub.resources.models import STR_SIZE
from ereuse_devicehub.resources.schemas import Thing
from ereuse_devicehub.teal.marshmallow import URL, EnumField, SanitizedStr
TRADE_VALUES = (
'id',
@ -18,16 +17,11 @@ TRADE_VALUES = (
'user_from.id',
'user_to.id',
'user_to.code',
'user_from.code'
'user_from.code',
)
DOCUMENTS_VALUES = (
'id',
'file_name',
'total_weight',
'trading'
)
DOCUMENTS_VALUES = ('id', 'file_name', 'total_weight', 'trading')
class Old_Lot(Thing):
@ -39,8 +33,9 @@ class Old_Lot(Thing):
children = 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__)
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
description=m.Lot.amount.__doc__)
amount = f.Integer(
validate=f.validate.Range(min=0, max=100), description=m.Lot.amount.__doc__
)
# author_id = NestedOn(s_user.User,only_query='author_id')
owner_id = f.UUID(data_key='ownerID')
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)
description = SanitizedStr(description=m.Lot.description.comment)
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
)

View file

@ -9,8 +9,6 @@ from marshmallow import Schema as MarshmallowSchema
from marshmallow import fields as f
from sqlalchemy import or_
from sqlalchemy.util import OrderedSet
from teal.marshmallow import EnumField
from teal.resource import View
from ereuse_devicehub.db import db
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.device.models import Computer, DataStorage, Device
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):
@ -79,7 +79,7 @@ class LotView(View):
lot = Lot.query.filter_by(id=id).one() # type: Lot
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):
"""Gets lots.

View file

@ -1,6 +1,6 @@
from teal.resource import Resource
from ereuse_devicehub.resources.metric.schema import Metric
from ereuse_devicehub.resources.metric.views import MetricsView
from ereuse_devicehub.teal.resource import Resource
class MetricDef(Resource):

View file

@ -1,11 +1,18 @@
from teal.resource import Schema
from marshmallow.fields import DateTime
from ereuse_devicehub.teal.resource import Schema
class Metric(Schema):
"""
This schema filter dates for search the metrics
"""
start_time = DateTime(data_key='start_time', required=True,
description="Start date for search metrics")
end_time = DateTime(data_key='end_time', required=True,
description="End date for search metrics")
start_time = DateTime(
data_key='start_time',
required=True,
description="Start date for search metrics",
)
end_time = DateTime(
data_key='end_time', required=True, description="End date for search metrics"
)

View file

@ -1,11 +1,18 @@
from flask import request, g, jsonify
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.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.metric.schema import Metric
from ereuse_devicehub.teal.resource import View
class MetricsView(View):
@ -41,4 +48,3 @@ class MetricsView(View):
count += 1
return count

View file

@ -4,10 +4,10 @@ from typing import Any
from marshmallow import post_load
from marshmallow.fields import DateTime, List, String
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.teal.marshmallow import URL
from ereuse_devicehub.teal.resource import Schema
class UnitCodes(Enum):
@ -38,8 +38,8 @@ class UnitCodes(Enum):
# Then the directive in our docs/config.py file reads these variables
# generating the documentation.
class Meta(type):
class Meta(type):
def __new__(cls, *args, **kw) -> Any:
base_name = args[1][0].__name__
y = super().__new__(cls, *args, **kw)
@ -47,7 +47,7 @@ class Meta(type):
return y
SchemaMeta.__bases__ = Meta,
SchemaMeta.__bases__ = (Meta,)
@classmethod
@ -70,9 +70,7 @@ value.
class Thing(Schema):
type = String(description=_type_description)
same_as = List(URL(dump_only=True),
dump_only=True,
data_key='sameAs')
same_as = List(URL(dump_only=True), dump_only=True, data_key='sameAs')
updated = DateTime('iso', dump_only=True, description=m.Thing.updated.comment)
created = DateTime('iso', dump_only=True, description=m.Thing.created.comment)

View file

@ -3,14 +3,18 @@ import pathlib
from click import argument, option
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.resources.device.definitions import DeviceDef
from ereuse_devicehub.resources.tag import schema
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):
@ -25,48 +29,77 @@ class TagDef(Resource):
'By default set to the actual Devicehub.'
CLI_SCHEMA = schema.Tag(only=('id', 'provider', 'org', 'secondary'))
def __init__(self, app: Teal, import_name=__name__.split('.')[0], static_folder=None,
def __init__(
self,
app: Teal,
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_tag, 'add'),
(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
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:
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(
'/<{0.ID_CONVERTER.value}:{0.ID_NAME}>/device'.format(self),
view_func=device_view,
methods={'GET'})
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
methods={'GET'},
)
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={'PUT'})
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
methods={'PUT'},
)
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'})
methods={'DELETE'},
)
@option('-u', '--owner', help=OWNER_H)
@option('-o', '--org', help=ORG_H)
@option('-p', '--provider', help=PROV_H)
@option('-s', '--sec', help=Tag.secondary.comment)
@argument('id')
def create_tag(self,
def create_tag(
self,
id: str,
org: str = None,
owner: str = None,
sec: str = None,
provider: str = None):
provider: str = None,
):
"""Create a tag with the given ID."""
db.session.add(Tag(**self.schema.load(
db.session.add(
Tag(
**self.schema.load(
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
)))
)
)
)
db.session.commit()
@option('-u', '--owner', help=OWNER_H)
@ -83,7 +116,17 @@ class TagDef(Resource):
"""
with path.open() as f:
for id, sec in csv.reader(f):
db.session.add(Tag(**self.schema.load(
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
)))
db.session.add(
Tag(
**self.schema.load(
dict(
id=id,
owner=owner,
org=org,
secondary=sec,
provider=provider,
)
)
)
)
db.session.commit()

View file

@ -3,12 +3,9 @@ from typing import Set
from boltons import urlutils
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.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.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.user.models import User
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']):
@ -26,51 +26,59 @@ class Tags(Set['Tag']):
return ', '.join(format(tag, format_spec) for tag in self).strip()
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
internally for software; users should not use this.
"""
id = Column(db.CIText(), primary_key=True)
id.comment = """The ID of the tag."""
owner_id = Column(UUID(as_uuid=True),
owner_id = Column(
UUID(as_uuid=True),
ForeignKey(User.id),
primary_key=True,
nullable=False,
default=lambda: g.user.id)
default=lambda: g.user.id,
)
owner = relationship(User, primaryjoin=owner_id == User.id)
org_id = Column(UUID(as_uuid=True),
org_id = Column(
UUID(as_uuid=True),
ForeignKey(Organization.id),
# If we link with the Organization object this instance
# will be set as persistent and added to session
# which is something we don't want to enforce by default
default=lambda: Organization.get_default_org_id())
org = relationship(Organization,
default=lambda: Organization.get_default_org_id(),
)
org = relationship(
Organization,
backref=backref('tags', lazy=True),
primaryjoin=Organization.id == org_id,
collection_class=set)
collection_class=set,
)
"""The organization that issued the tag."""
provider = Column(URL())
provider.comment = """The tag provider URL. If None, the provider is
this Devicehub.
"""
device_id = Column(BigInteger,
device_id = Column(
BigInteger,
# We don't want to delete the tag on device deletion, only set to null
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL))
device = relationship(Device,
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL),
)
device = relationship(
Device,
backref=backref('tags', lazy=True, collection_class=Tags),
primaryjoin=Device.id == device_id)
primaryjoin=Device.id == device_id,
)
"""The device linked to this tag."""
secondary = Column(db.CIText(), index=True)
secondary.comment = """A secondary identifier for this tag.
It has the same constraints as the main one. Only needed in special cases.
"""
__table_args__ = (
db.Index('device_id_index', device_id, postgresql_using='hash'),
)
__table_args__ = (db.Index('device_id_index', device_id, postgresql_using='hash'),)
def __init__(self, id: str, **kwargs) -> None:
super().__init__(id=id, **kwargs)
@ -99,13 +107,16 @@ class Tag(Thing):
@validates('provider')
def use_only_domain(self, _, url: URL):
if url.path:
raise ValidationError('Provider can only contain scheme and host',
field_names=['provider'])
raise ValidationError(
'Provider can only contain scheme and host', field_names=['provider']
)
return url
__table_args__ = (
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

View file

@ -1,6 +1,5 @@
from marshmallow.fields import Boolean
from sqlalchemy.util import OrderedSet
from teal.marshmallow import SanitizedStr, URL
from ereuse_devicehub.marshmallow import NestedOn
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.tag import model as m
from ereuse_devicehub.resources.user.schemas import User
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
def without_slash(x: str) -> bool:
@ -16,12 +16,10 @@ def without_slash(x: str) -> bool:
class Tag(Thing):
id = SanitizedStr(lower=True,
description=m.Tag.id.comment,
validator=without_slash,
required=True)
provider = URL(description=m.Tag.provider.comment,
validator=without_slash)
id = SanitizedStr(
lower=True, description=m.Tag.id.comment, validator=without_slash, required=True
)
provider = URL(description=m.Tag.provider.comment, validator=without_slash)
device = NestedOn(Device, dump_only=True)
owner = NestedOn(User, only_query='id')
org = NestedOn(Organization, collection_class=OrderedSet, only_query='id')

View file

@ -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 teal.marshmallow import ValidationError
from teal.resource import View, url_for_resource
from ereuse_devicehub import auth
from ereuse_devicehub.db import db
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.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):
@ -34,13 +36,19 @@ class TagView(View):
@auth.Auth.requires_auth
def find(self, args: dict):
tags = Tag.query.filter(Tag.is_printable_q()) \
.filter_by(owner=g.user) \
.order_by(Tag.created.desc()) \
.paginate(per_page=200) # type: Pagination
tags = (
Tag.query.filter(Tag.is_printable_q())
.filter_by(owner=g.user)
.order_by(Tag.created.desc())
.paginate(per_page=200)
) # type: Pagination
return things_response(
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):
@ -48,7 +56,9 @@ class TagView(View):
tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id]
db.session.add_all(tags)
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()
return response

View file

@ -1,7 +1,7 @@
from teal.resource import Converters, Resource
from ereuse_devicehub.resources.tradedocument import schemas
from ereuse_devicehub.resources.tradedocument.views import TradeDocumentView
from ereuse_devicehub.teal.resource import Converters, Resource
class TradeDocumentDef(Resource):
SCHEMA = schemas.TradeDocument

View file

@ -7,12 +7,12 @@ from sortedcontainers import SortedSet
from sqlalchemy import BigInteger, Column, Sequence
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import backref
from teal.db import CASCADE_OWN, URL
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.enums import Severity
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
_sorted_documents = {
'order_by': lambda: TradeDocument.created,

View file

@ -1,10 +1,13 @@
from marshmallow.fields import DateTime, Integer, Float, validate
from teal.marshmallow import SanitizedStr, URL
# from marshmallow import ValidationError, validates_schema
from marshmallow.fields import DateTime, Float, Integer, validate
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.schemas import Thing
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
@ -12,20 +15,28 @@ class TradeDocument(Thing):
__doc__ = m.TradeDocument.__doc__
id = Integer(description=m.TradeDocument.id.comment, dump_only=True)
date = DateTime(required=False, description=m.TradeDocument.date.comment)
id_document = SanitizedStr(data_key='documentId',
id_document = SanitizedStr(
data_key='documentId',
default='',
description=m.TradeDocument.id_document.comment,
)
description = SanitizedStr(
default='',
description=m.TradeDocument.id_document.comment)
description = SanitizedStr(default='',
description=m.TradeDocument.description.comment,
validate=validate.Length(max=500))
file_name = SanitizedStr(data_key='filename',
validate=validate.Length(max=500),
)
file_name = SanitizedStr(
data_key='filename',
default='',
description=m.TradeDocument.file_name.comment,
validate=validate.Length(max=100))
file_hash = SanitizedStr(data_key='hash',
validate=validate.Length(max=100),
)
file_hash = SanitizedStr(
data_key='hash',
default='',
description=m.TradeDocument.file_hash.comment,
validate=validate.Length(max=64))
validate=validate.Length(max=64),
)
url = URL(description=m.TradeDocument.url.comment)
lot = NestedOn('Lot', only_query='id', description=m.TradeDocument.lot.__doc__)
trading = SanitizedStr(dump_only=True, description='')

View file

@ -1,18 +1,20 @@
import os
import time
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 teal.resource import View
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.hash_reports import ReportHash
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
from ereuse_devicehub.teal.resource import View
class TradeDocumentView(View):
def one(self, id: str):
doc = TradeDocument.query.filter_by(id=id, owner=g.user).one()
return self.schema.jsonify(doc)
@ -33,10 +35,9 @@ class TradeDocumentView(View):
trade = doc.lot.trade
if trade:
trade.documents.add(doc)
confirm = ConfirmDocument(action=trade,
user=g.user,
devices=set(),
documents={doc})
confirm = ConfirmDocument(
action=trade, user=g.user, devices=set(), documents={doc}
)
db.session.add(confirm)
db.session.add(doc)
db.session().final_flush()

View file

@ -2,12 +2,12 @@ from typing import Iterable
from click import argument, option
from flask import current_app
from teal.resource import Converters, Resource
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.user import schemas
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.resources.user.views import UserView, login, logout
from ereuse_devicehub.teal.resource import Converters, Resource
class UserDef(Resource):
@ -16,49 +16,88 @@ class UserDef(Resource):
ID_CONVERTER = Converters.uuid
AUTH = True
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
static_url_path=None, template_folder=None, url_prefix=None, subdomain=None,
url_defaults=None, root_path=None):
def __init__(
self,
app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
):
cli_commands = ((self.create_user, 'add'),)
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,
)
self.add_url_rule('/login/', view_func=login, methods={'POST'})
logout_view = app.auth.requires_auth(logout)
self.add_url_rule('/logout/', view_func=logout_view, methods={'GET'})
@argument('email')
@option('-i', '--inventory',
@option(
'-i',
'--inventory',
multiple=True,
help='Inventories user has access to. By default this one.')
@option('-a', '--agent',
help='Inventories user has access to. By default this one.',
)
@option(
'-a',
'--agent',
help='Create too an Individual agent representing this user, '
'and give a name to this individual.')
'and give a name to this individual.',
)
@option('-c', '--country', help='The country of the agent (if --agent is set).')
@option('-t', '--telephone', help='The telephone of the agent (if --agent is set).')
@option('-t', '--tax-id', help='The tax id of the agent (if --agent is set).')
@option('-p', '--password', prompt=True, hide_input=True, confirmation_prompt=True)
def create_user(self, email: str,
def create_user(
self,
email: str,
password: str,
inventory: Iterable[str] = tuple(),
agent: str = None,
country: str = None,
telephone: str = None,
tax_id: str = None) -> dict:
tax_id: str = None,
) -> dict:
"""Create an user.
If ``--agent`` is passed, it creates too an ``Individual``
agent that represents the user.
"""
from ereuse_devicehub.resources.agent.models import Individual
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \
.load({'email': email, 'password': password})
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)).load(
{'email': email, 'password': password}
)
if inventory:
from ereuse_devicehub.resources.inventory import Inventory
inventory = Inventory.query.filter(Inventory.id.in_(inventory))
user = User(**u, inventories=inventory)
agent = Individual(**current_app.resources[Individual.t].schema.load(
dict(name=agent, email=email, country=country, telephone=telephone, taxId=tax_id)
))
agent = Individual(
**current_app.resources[Individual.t].schema.load(
dict(
name=agent,
email=email,
country=country,
telephone=telephone,
taxId=tax_id,
)
)
)
user.individuals.add(agent)
db.session.add(user)
db.session.commit()

View file

@ -6,12 +6,12 @@ from flask_login import UserMixin
from sqlalchemy import BigInteger, Boolean, Column, Sequence
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy_utils import EmailType, PasswordType
from teal.db import CASCADE_OWN, URL, IntEnum
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.enums import SessionType
from ereuse_devicehub.resources.inventory.model import Inventory
from ereuse_devicehub.resources.models import STR_SIZE, Thing
from ereuse_devicehub.teal.db import CASCADE_OWN, URL, IntEnum
class User(UserMixin, Thing):

View file

@ -1,12 +1,12 @@
from marshmallow import post_dump
from marshmallow.fields import Email, String, UUID
from teal.marshmallow import SanitizedStr
from marshmallow.fields import UUID, Email, String
from ereuse_devicehub import auth
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.agent.schemas import Individual
from ereuse_devicehub.resources.inventory.schema import Inventory
from ereuse_devicehub.resources.schemas import Thing
from ereuse_devicehub.teal.marshmallow import SanitizedStr
class Session(Thing):
@ -19,13 +19,16 @@ class User(Thing):
password = SanitizedStr(load_only=True, required=True)
individuals = NestedOn(Individual, many=True, dump_only=True)
name = SanitizedStr()
token = String(dump_only=True,
token = String(
dump_only=True,
description='Use this token in an Authorization header to access the app.'
'The token can change overtime.')
'The token can change overtime.',
)
inventories = NestedOn(Inventory, many=True, dump_only=True)
code = String(dump_only=True, description='Code of inactive accounts')
def __init__(self,
def __init__(
self,
only=None,
exclude=('token',),
prefix='',
@ -33,13 +36,16 @@ class User(Thing):
context=None,
load_only=(),
dump_only=(),
partial=False):
partial=False,
):
"""Instantiates the User.
By default we exclude token from both load/dump
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
def base64encode_token(self, data: dict):

View file

@ -2,11 +2,11 @@ from uuid import UUID, uuid4
from flask import g, request
from flask.json import jsonify
from teal.resource import View
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.resource import View
class UserView(View):
@ -19,7 +19,9 @@ def login():
user_s = g.resource_def.SCHEMA(only=('email', 'password')) # type: UserS
# noinspection PyArgumentList
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']:
schema_with_token = g.resource_def.SCHEMA(exclude=set())
return schema_with_token.jsonify(user)

View file

@ -1,16 +1,16 @@
import flask
import json
import requests
import teal.marshmallow
from typing import Callable, Iterable, Tuple
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.resources.inventory.model import Inventory
from ereuse_devicehub.teal.resource import Resource, View
def get_tag_version(app):
@ -29,6 +29,7 @@ def get_tag_version(app):
else:
return {}
class VersionView(View):
def get(self, *args, **kwargs):
"""Get version of DeviceHub and ereuse-tag."""
@ -48,7 +49,9 @@ class VersionDef(Resource):
VIEW = None # We do not want to create default / documents endpoint
AUTH = False
def __init__(self, app,
def __init__(
self,
app,
import_name=__name__,
static_folder=None,
static_url_path=None,
@ -57,9 +60,20 @@ class VersionDef(Resource):
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)
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"}
get = {'GET'}

View file

View 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]

View 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

View 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

View 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])

View 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
View 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
)

File diff suppressed because it is too large Load diff

View 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)

View 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]

View 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"}&param2=["x", "y"]``, and it still allows
normal non-JSON-encoded params ``../foo?param=23&param2={"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

View 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

View 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)

View 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,)

View 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

View file

@ -14,7 +14,6 @@ from flask import current_app as app
from flask import g
from pytest import raises
from sqlalchemy.util import OrderedSet
from teal.enums import Currency
from ereuse_devicehub.client import Client, UserClient
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.tradedocument.models import TradeDocument
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.enums import Currency
from tests import conftest
from tests.conftest import create_user, file, json_encode, yaml2json

View file

@ -3,25 +3,32 @@ from uuid import UUID
import pytest
from marshmallow import ValidationError
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.db import db
from ereuse_devicehub.devicehub import Devicehub
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
@pytest.mark.usefixtures(app_context.__name__)
def test_agent():
"""Tests creating an person."""
person = Person(name='Timmy',
person = Person(
name='Timmy',
tax_id='xyz',
country=Country.ES,
telephone=PhoneNumber('+34666666666'),
email='foo@bar.com')
email='foo@bar.com',
)
db.session.add(person)
db.session.commit()
@ -36,8 +43,7 @@ def test_agent():
@pytest.mark.usefixtures(app_context.__name__)
def test_system():
"""Tests creating a system."""
system = System(name='Workbench',
email='hello@ereuse.org')
system = System(name='Workbench', email='hello@ereuse.org')
db.session.add(system)
db.session.commit()
@ -49,10 +55,9 @@ def test_system():
@pytest.mark.usefixtures(app_context.__name__)
def test_organization():
"""Tests creating an organization."""
org = Organization(name='ACME',
tax_id='xyz',
country=Country.ES,
email='contact@acme.com')
org = Organization(
name='ACME', tax_id='xyz', country=Country.ES, email='contact@acme.com'
)
db.session.add(org)
db.session.commit()

View file

@ -2,7 +2,8 @@ import datetime
from uuid import UUID
import pytest
from teal.db import UniqueViolation
from ereuse_devicehub.teal.db import UniqueViolation
@pytest.mark.mvp
@ -12,9 +13,10 @@ def test_unique_violation():
self.params = {
'uuid': UUID('f5efd26e-8754-46bc-87bf-fbccc39d60d9'),
'version': '11.0',
'software': 'Workbench', 'elapsed': datetime.timedelta(0, 4),
'software': 'Workbench',
'elapsed': datetime.timedelta(0, 4),
'expected_actions': None,
'id': UUID('dbdef3d8-2cac-48cb-adb8-419bc3e59687')
'id': UUID('dbdef3d8-2cac-48cb-adb8-419bc3e59687'),
}
def __str__(self):

View file

@ -9,8 +9,6 @@ from ereuse_utils.test import ANY
from flask import g
from pytest import raises
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.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.user import User
from ereuse_devicehub.teal.db import ResourceNotFound
from ereuse_devicehub.teal.enums import Layouts
from tests import conftest
from tests.conftest import file, json_encode, yaml2json

View file

@ -1,40 +1,46 @@
import pytest
import uuid
from teal.utils import compiled
import pytest
from ereuse_devicehub.client import UserClient
from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.action.models import Snapshot
from ereuse_devicehub.resources.device.models import Desktop, Device, GraphicCard, Laptop, Server, \
SolidStateDrive
from ereuse_devicehub.resources.device.models import (
Desktop,
Device,
GraphicCard,
Laptop,
Server,
SolidStateDrive,
)
from ereuse_devicehub.resources.device.search import DeviceSearch
from ereuse_devicehub.resources.device.views import Filters, Sorting
from ereuse_devicehub.resources.enums import ComputerChassis
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.teal.utils import compiled
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.usefixtures(conftest.app_context.__name__)
def test_device_filters():
schema = Filters()
q = schema.load({
q = schema.load(
{
'type': ['Computer', 'Laptop'],
'manufacturer': 'Dell',
'rating': {
'rating': [3, 6],
'appearance': [2, 4]
},
'tag': {
'id': ['bcn-', 'activa-02']
'rating': {'rating': [3, 6], 'appearance': [2, 4]},
'tag': {'id': ['bcn-', 'activa-02']},
}
})
)
s, params = compiled(Device, q)
# Order between query clauses can change
assert '(device.type IN (%(type_1)s, %(type_2)s, %(type_3)s, %(type_4)s) ' \
assert (
'(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 '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
@ -42,11 +48,33 @@ def test_device_filters():
# type_x can be assigned at different values
# 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',
'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-%'}
assert set(params.keys()) == {
'id_2',
'appearance_1',
'type_1',
'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__)
@ -70,22 +98,30 @@ def device_query_dummy(app: Devicehub):
"""
with app.app_context():
devices = ( # The order matters ;-)
Desktop(serial_number='1',
Desktop(
serial_number='1',
model='ml1',
manufacturer='mr1',
chassis=ComputerChassis.Tower),
Desktop(serial_number='2',
chassis=ComputerChassis.Tower,
),
Desktop(
serial_number='2',
model='ml2',
manufacturer='mr2',
chassis=ComputerChassis.Microtower),
Laptop(serial_number='3',
chassis=ComputerChassis.Microtower,
),
Laptop(
serial_number='3',
model='ml3',
manufacturer='mr3',
chassis=ComputerChassis.Detachable),
Server(serial_number='4',
chassis=ComputerChassis.Detachable,
),
Server(
serial_number='4',
model='ml4',
manufacturer='mr4',
chassis=ComputerChassis.Tower),
chassis=ComputerChassis.Tower,
),
)
devices[0].components.add(
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__)
def test_device_query_filter_sort(user: UserClient):
i, _ = user.get(res=Device, query=[
i, _ = user.get(
res=Device,
query=[
('sort', {'created': Sorting.DESCENDING}),
('filter', {'type': ['Computer']})
])
('filter', {'type': ['Computer']}),
],
)
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)
child, _ = user.post({'name': 'Child'}, res=Lot)
i, _ = user.get(res=Device, query=[
('filter', {'lot': {'id': [parent['id']]}})
])
i, _ = user.get(res=Device, query=[('filter', {'lot': {'id': [parent['id']]}})])
assert not i['items'], 'No devices in lot'
parent, _ = user.post({},
parent, _ = user.post(
{},
res=Lot,
item='{}/children'.format(parent['id']),
query=[('id', child['id'])])
i, _ = user.get(res=Device, query=[
('filter', {'type': ['Computer']})
])
query=[('id', child['id'])],
)
i, _ = user.get(res=Device, query=[('filter', {'type': ['Computer']})])
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']),
query=[('id', d['id']) for d in i['items'][:2]])
child, _ = user.post({},
query=[('id', d['id']) for d in i['items'][:2]],
)
child, _ = user.post(
{},
res=Lot,
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']]}})
])
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(
x['serialNumber'] for x in i['items']
), 'The parent lot contains 2 items plus indirectly the other ' \
), (
'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(
res=Device,
query=[
('filter', {'type': ['Computer'], 'lot': {'id': [parent['id']]}}),
])
],
)
assert ('1', '2', '3', '4') == tuple(x['serialNumber'] for x in i['items'])
s, _ = user.get(res=Device, query=[
('filter', {'lot': {'id': [child['id']]}})
])
s, _ = user.get(res=Device, query=[('filter', {'lot': {'id': [child['id']]}})])
assert ('3', '4', '4-ssd') == tuple(x['serialNumber'] for x in s['items'])
s, _ = user.get(res=Device, query=[
('filter', {'lot': {'id': [child['id'], parent['id']]}})
])
s, _ = user.get(
res=Device, query=[('filter', {'lot': {'id': [child['id'], parent['id']]}})]
)
assert ('1', '2', '3', '4', '1-gc', '2-ssd', '4-ssd') == tuple(
x['serialNumber'] for x in s['items']
), 'Adding both lots is redundant in this case and we have the 4 elements.'

View file

@ -12,8 +12,6 @@ import pytest
from boltons import urlutils
from ereuse_utils.test import ANY
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.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.tag import Tag
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.conftest import file, file_json, json_encode, yaml2json

View file

@ -6,8 +6,6 @@ from boltons.urlutils import URL
from ereuse_utils.session import DevicehubClient
from flask import g
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.db import db
@ -23,6 +21,13 @@ from ereuse_devicehub.resources.tag.view import (
TagNotLinked,
)
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.conftest import json_encode, yaml2json

View file

@ -2,8 +2,6 @@ from uuid import UUID
import pytest
from sqlalchemy_utils import Password
from teal.enums import Country
from teal.marshmallow import ValidationError
from werkzeug.exceptions import NotFound
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.exceptions import WrongCredentials
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
@ -24,12 +24,14 @@ def test_create_user_method_with_agent(app: Devicehub):
This method checks that the token is correct, too.
"""
user_def = app.resources['User'] # type: UserDef
u = user_def.create_user(email='foo@foo.com',
u = user_def.create_user(
email='foo@foo.com',
password='foo',
agent='Nice Person',
country=Country.ES.name,
telephone='+34 666 66 66 66',
tax_id='1234')
tax_id='1234',
)
user = User.query.filter_by(id=u['id']).one() # type: User
assert user.email == 'foo@foo.com'
assert isinstance(user.token, UUID)
@ -75,9 +77,9 @@ def test_login_success(client: Client, app: Devicehub):
"""
with app.app_context():
create_user()
user, _ = client.post({'email': 'foo@foo.com', 'password': 'foo'},
uri='/users/login/',
status=200)
user, _ = client.post(
{'email': 'foo@foo.com', 'password': 'foo'}, uri='/users/login/', status=200
)
assert user['email'] == 'foo@foo.com'
assert UUID(auth.Auth.decode(user['token']))
assert 'password' not in user
@ -126,16 +128,20 @@ def test_login_failure(client: Client, app: Devicehub):
# Wrong password
with app.app_context():
create_user()
client.post({'email': 'foo@foo.com', 'password': 'wrong pass'},
client.post(
{'email': 'foo@foo.com', 'password': 'wrong pass'},
uri='/users/login/',
status=WrongCredentials)
status=WrongCredentials,
)
# Wrong URI
client.post({}, uri='/wrong-uri', status=NotFound)
# Malformed data
client.post({}, uri='/users/login/', status=ValidationError)
client.post({'email': 'this is not an email', 'password': 'nope'},
client.post(
{'email': 'this is not an email', 'password': 'nope'},
uri='/users/login/',
status=ValidationError)
status=ValidationError,
)
@pytest.mark.xfail(reason='Test not developed')