diff --git a/ereuse_devicehub/auth.py b/ereuse_devicehub/auth.py index 19f6e5fa..f6f5477e 100644 --- a/ereuse_devicehub/auth.py +++ b/ereuse_devicehub/auth.py @@ -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): diff --git a/ereuse_devicehub/client.py b/ereuse_devicehub/client.py index 92aa67d2..25d79198 100644 --- a/ereuse_devicehub/client.py +++ b/ereuse_devicehub/client.py @@ -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] diff --git a/ereuse_devicehub/config.py b/ereuse_devicehub/config.py index a41c9640..1b89006a 100644 --- a/ereuse_devicehub/config.py +++ b/ereuse_devicehub/config.py @@ -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): diff --git a/ereuse_devicehub/db.py b/ereuse_devicehub/db.py index 964de614..16ede4fc 100644 --- a/ereuse_devicehub/db.py +++ b/ereuse_devicehub/db.py @@ -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 diff --git a/ereuse_devicehub/devicehub.py b/ereuse_devicehub/devicehub.py index 82f5dd4a..086895a3 100644 --- a/ereuse_devicehub/devicehub.py +++ b/ereuse_devicehub/devicehub.py @@ -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 diff --git a/ereuse_devicehub/inventory/models.py b/ereuse_devicehub/inventory/models.py index f8b4f977..83fd6619 100644 --- a/ereuse_devicehub/inventory/models.py +++ b/ereuse_devicehub/inventory/models.py @@ -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): diff --git a/ereuse_devicehub/marshmallow.py b/ereuse_devicehub/marshmallow.py index 2ff78d99..784d6100 100644 --- a/ereuse_devicehub/marshmallow.py +++ b/ereuse_devicehub/marshmallow.py @@ -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, + ) diff --git a/ereuse_devicehub/query.py b/ereuse_devicehub/query.py index c5bd1528..c7e26569 100644 --- a/ereuse_devicehub/query.py +++ b/ereuse_devicehub/query.py @@ -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,29 +15,33 @@ class SearchQueryParser(NestedQueryFlaskParser): return v -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: +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: """Generates a Devicehub API list conformant response for multiple things. """ - response = jsonify({ - 'items': items, - # todo pagination should be in Header like github - # https://developer.github.com/v3/guides/traversing-with-pagination/ - 'pagination': { - 'page': page, - 'perPage': per_page, - 'total': total, - 'previous': previous, - 'next': next - }, - 'url': url or request.path - }) + response = jsonify( + { + 'items': items, + # todo pagination should be in Header like github + # https://developer.github.com/v3/guides/traversing-with-pagination/ + 'pagination': { + 'page': page, + 'perPage': per_page, + 'total': total, + 'previous': previous, + 'next': next, + }, + 'url': url or request.path, + } + ) response.status_code = code return response diff --git a/ereuse_devicehub/resources/action/__init__.py b/ereuse_devicehub/resources/action/__init__.py index b405e164..96c966f4 100644 --- a/ereuse_devicehub/resources/action/__init__.py +++ b/ereuse_devicehub/resources/action/__init__.py @@ -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, - 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()): + 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(), + ): 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() diff --git a/ereuse_devicehub/resources/action/models.py b/ereuse_devicehub/resources/action/models.py index b723bab8..41a65265 100644 --- a/ereuse_devicehub/resources/action/models.py +++ b/ereuse_devicehub/resources/action/models.py @@ -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( diff --git a/ereuse_devicehub/resources/action/schemas.py b/ereuse_devicehub/resources/action/schemas.py index da70c49a..6b10ca69 100644 --- a/ereuse_devicehub/resources/action/schemas.py +++ b/ereuse_devicehub/resources/action/schemas.py @@ -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): diff --git a/ereuse_devicehub/resources/action/views/trade.py b/ereuse_devicehub/resources/action/views/trade.py index 4e2a31d5..2624a74c 100644 --- a/ereuse_devicehub/resources/action/views/trade.py +++ b/ereuse_devicehub/resources/action/views/trade.py @@ -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: diff --git a/ereuse_devicehub/resources/action/views/views.py b/ereuse_devicehub/resources/action/views/views.py index 25814b4a..a9c1d664 100644 --- a/ereuse_devicehub/resources/action/views/views.py +++ b/ereuse_devicehub/resources/action/views/views.py @@ -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') diff --git a/ereuse_devicehub/resources/agent/__init__.py b/ereuse_devicehub/resources/agent/__init__.py index 20d4945d..dcbb566b 100644 --- a/ereuse_devicehub/resources/agent/__init__.py +++ b/ereuse_devicehub/resources/agent/__init__.py @@ -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, - 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_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) diff --git a/ereuse_devicehub/resources/agent/models.py b/ereuse_devicehub/resources/agent/models.py index 826d0545..7204423e 100644 --- a/ereuse_devicehub/resources/agent/models.py +++ b/ereuse_devicehub/resources/agent/models.py @@ -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: diff --git a/ereuse_devicehub/resources/agent/schemas.py b/ereuse_devicehub/resources/agent/schemas.py index 24109c18..8490e1de 100644 --- a/ereuse_devicehub/resources/agent/schemas.py +++ b/ereuse_devicehub/resources/agent/schemas.py @@ -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() diff --git a/ereuse_devicehub/resources/deliverynote/__init__.py b/ereuse_devicehub/resources/deliverynote/__init__.py index cf49bacd..112bb1e9 100644 --- a/ereuse_devicehub/resources/deliverynote/__init__.py +++ b/ereuse_devicehub/resources/deliverynote/__init__.py @@ -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,15 +11,28 @@ class DeliverynoteDef(Resource): AUTH = True ID_CONVERTER = Converters.uuid - 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) + 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, + ) diff --git a/ereuse_devicehub/resources/deliverynote/models.py b/ereuse_devicehub/resources/deliverynote/models.py index 57eefe08..6407c2ef 100644 --- a/ereuse_devicehub/resources/deliverynote/models.py +++ b/ereuse_devicehub/resources/deliverynote/models.py @@ -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), - db.ForeignKey(User.id), - nullable=False, - default=lambda: g.user.id) + creator_id = db.Column( + UUID(as_uuid=True), + db.ForeignKey(User.id), + nullable=False, + default=lambda: g.user.id, + ) creator = db.relationship(User, primaryjoin=creator_id == User.id) - 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(), - db.ForeignKey(User.email), - nullable=False, - default=lambda: g.user.email) - receiver = db.relationship(User, primaryjoin=lambda: Deliverynote.receiver_address == User.email) + 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(), + db.ForeignKey(User.email), + nullable=False, + default=lambda: g.user.email, + ) + receiver = db.relationship( + User, primaryjoin=lambda: Deliverynote.receiver_address == User.email + ) date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) date.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, - backref=db.backref('deliverynote', uselist=False, lazy=True), - lazy=True, - primaryjoin=Lot.id == lot_id) + 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, + ) - 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, - supplier_email=supplier_email, - expected_devices=expected_devices, - transfer_state=transfer_state) + 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, + supplier_email=supplier_email, + expected_devices=expected_devices, + transfer_state=transfer_state, + ) @property def type(self) -> str: diff --git a/ereuse_devicehub/resources/deliverynote/schemas.py b/ereuse_devicehub/resources/deliverynote/schemas.py index f0dbecdc..30955279 100644 --- a/ereuse_devicehub/resources/deliverynote/schemas.py +++ b/ereuse_devicehub/resources/deliverynote/schemas.py @@ -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) diff --git a/ereuse_devicehub/resources/deliverynote/views.py b/ereuse_devicehub/resources/deliverynote/views.py index 1f21a6a1..9d2a27c0 100644 --- a/ereuse_devicehub/resources/deliverynote/views.py +++ b/ereuse_devicehub/resources/deliverynote/views.py @@ -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) diff --git a/ereuse_devicehub/resources/device/definitions.py b/ereuse_devicehub/resources/device/definitions.py index d5678135..febacf38 100644 --- a/ereuse_devicehub/resources/device/definitions.py +++ b/ereuse_devicehub/resources/device/definitions.py @@ -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): diff --git a/ereuse_devicehub/resources/device/exceptions.py b/ereuse_devicehub/resources/device/exceptions.py index 0b98c381..f40c569d 100644 --- a/ereuse_devicehub/resources/device/exceptions.py +++ b/ereuse_devicehub/resources/device/exceptions.py @@ -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, - tag_id, - message=None, - field_names=None, - fields=None, - data=None, - valid_data=None, - **kwargs): + def __init__( + self, + tag_id, + message=None, + field_names=None, + fields=None, + data=None, + valid_data=None, + **kwargs, + ): message = message or 'Device {} is from another Devicehub.'.format(tag_id) super().__init__(message, field_names, fields, data, valid_data, **kwargs) diff --git a/ereuse_devicehub/resources/device/models.py b/ereuse_devicehub/resources/device/models.py index 05af6f9b..87ef6fd6 100644 --- a/ereuse_devicehub/resources/device/models.py +++ b/ereuse_devicehub/resources/device/models.py @@ -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): diff --git a/ereuse_devicehub/resources/device/schemas.py b/ereuse_devicehub/resources/device/schemas.py index 76a1fee2..527c7328 100644 --- a/ereuse_devicehub/resources/device/schemas.py +++ b/ereuse_devicehub/resources/device/schemas.py @@ -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): diff --git a/ereuse_devicehub/resources/device/sync.py b/ereuse_devicehub/resources/device/sync.py index 32c4a2f2..38b5793d 100644 --- a/ereuse_devicehub/resources/device/sync.py +++ b/ereuse_devicehub/resources/device/sync.py @@ -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', diff --git a/ereuse_devicehub/resources/device/views.py b/ereuse_devicehub/resources/device/views.py index 59beffd9..a8d0e88d 100644 --- a/ereuse_devicehub/resources/device/views.py +++ b/ereuse_devicehub/resources/device/views.py @@ -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): diff --git a/ereuse_devicehub/resources/documents/documents.py b/ereuse_devicehub/resources/documents/documents.py index 363fda83..78a69314 100644 --- a/ereuse_devicehub/resources/documents/documents.py +++ b/ereuse_devicehub/resources/documents/documents.py @@ -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: diff --git a/ereuse_devicehub/resources/documents/models.py b/ereuse_devicehub/resources/documents/models.py index 9c557b3e..d3ae78d3 100644 --- a/ereuse_devicehub/resources/documents/models.py +++ b/ereuse_devicehub/resources/documents/models.py @@ -1,20 +1,19 @@ +from citext import CIText from flask import g -from citext import CIText 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), - db.ForeignKey(User.id), - nullable=False, - default=lambda: g.user.id) + 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, + ) 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.""" diff --git a/ereuse_devicehub/resources/documents/schemas.py b/ereuse_devicehub/resources/documents/schemas.py index d62cec41..e16811db 100644 --- a/ereuse_devicehub/resources/documents/schemas.py +++ b/ereuse_devicehub/resources/documents/schemas.py @@ -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) + url = URL(required=False, description=m.DataWipeDocument.url.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', - required=False, - default='', - 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', - default='', - description=m.DataWipeDocument.file_hash.comment, - validate=validate.Length(max=64)) + 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', + default='', + description=m.DataWipeDocument.file_name.comment, + validate=validate.Length(max=100), + ) + file_hash = SanitizedStr( + data_key='hash', + default='', + description=m.DataWipeDocument.file_hash.comment, + validate=validate.Length(max=64), + ) @post_load def get_trade_document(self, data): diff --git a/ereuse_devicehub/resources/image/models.py b/ereuse_devicehub/resources/image/models.py index 06f45bad..522ec1b2 100644 --- a/ereuse_devicehub/resources/image/models.py +++ b/ereuse_devicehub/resources/image/models.py @@ -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, - primaryjoin=Device.id == device_id, - backref=backref('images', - lazy=True, - cascade=CASCADE_OWN, - order_by=lambda: ImageList.created, - collection_class=OrderedSet)) + device = relationship( + Device, + primaryjoin=Device.id == device_id, + backref=backref( + 'images', + lazy=True, + cascade=CASCADE_OWN, + order_by=lambda: ImageList.created, + 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, - primaryjoin=ImageList.id == image_list_id, - backref=backref('images', - cascade=CASCADE_OWN, - order_by=lambda: Image.created, - collection_class=OrderedSet)) + image_list = relationship( + ImageList, + primaryjoin=ImageList.id == image_list_id, + backref=backref( + 'images', + cascade=CASCADE_OWN, + order_by=lambda: Image.created, + collection_class=OrderedSet, + ), + ) # todo make an image Field that converts to/from image object # todo which metadata we get from Photobox? diff --git a/ereuse_devicehub/resources/inventory/__init__.py b/ereuse_devicehub/resources/inventory/__init__.py index 8f57cada..201c38cb 100644 --- a/ereuse_devicehub/resources/inventory/__init__.py +++ b/ereuse_devicehub/resources/inventory/__init__.py @@ -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, - 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) + 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, + ) @classmethod - def set_inventory_config(cls, - name: str = None, - org_name: str = None, - org_id: str = None, - tag_url: boltons.urlutils.URL = None, - tag_token: uuid.UUID = None): + def set_inventory_config( + cls, + name: str = None, + org_name: str = None, + org_id: str = None, + tag_url: boltons.urlutils.URL = None, + tag_token: uuid.UUID = None, + ): try: inventory = Inventory.current except ResourceNotFound: # No inventory defined in db yet - inventory = Inventory(id=current_app.id, - name=name, - tag_provider=tag_url, - tag_token=tag_token) + 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) diff --git a/ereuse_devicehub/resources/licences/licences.py b/ereuse_devicehub/resources/licences/licences.py index f664fe4e..e39ff331 100644 --- a/ereuse_devicehub/resources/licences/licences.py +++ b/ereuse_devicehub/resources/licences/licences.py @@ -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,18 +25,31 @@ class LicencesDef(Resource): VIEW = None # We do not want to create default / documents endpoint AUTH = False - 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()): - super().__init__(app, import_name, static_folder, static_url_path, template_folder, - url_prefix, subdomain, url_defaults, root_path, cli_commands) + 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(), + ): + super().__init__( + app, + import_name, + static_folder, + static_url_path, + template_folder, + url_prefix, + subdomain, + url_defaults, + root_path, + cli_commands, + ) get = {'GET'} d = {} diff --git a/ereuse_devicehub/resources/lot/__init__.py b/ereuse_devicehub/resources/lot/__init__.py index d76bc54b..b78761cf 100644 --- a/ereuse_devicehub/resources/lot/__init__.py +++ b/ereuse_devicehub/resources/lot/__init__.py @@ -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, - 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) + 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 + ) if self.AUTH: lot_children = app.auth.requires_auth(lot_children) - self.add_url_rule('/<{}:{}>/children'.format(self.ID_CONVERTER.value, self.ID_NAME), - view_func=lot_children, - methods={'POST', 'DELETE'}) + self.add_url_rule( + '/<{}:{}>/children'.format(self.ID_CONVERTER.value, self.ID_NAME), + view_func=lot_children, + 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), - view_func=lot_device, - methods={'POST', 'DELETE'}) + self.add_url_rule( + '/<{}:{}>/devices'.format(self.ID_CONVERTER.value, self.ID_NAME), + view_func=lot_device, + methods={'POST', 'DELETE'}, + ) def init_db(self, db: 'db.SQLAlchemy', exclude_schema=None): # Create functions diff --git a/ereuse_devicehub/resources/lot/models.py b/ereuse_devicehub/resources/lot/models.py index 051cf08c..23a7ef0c 100644 --- a/ereuse_devicehub/resources/lot/models.py +++ b/ereuse_devicehub/resources/lot/models.py @@ -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): diff --git a/ereuse_devicehub/resources/lot/schemas.py b/ereuse_devicehub/resources/lot/schemas.py index 9a5a58aa..623f4754 100644 --- a/ereuse_devicehub/resources/lot/schemas.py +++ b/ereuse_devicehub/resources/lot/schemas.py @@ -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 + ) diff --git a/ereuse_devicehub/resources/lot/views.py b/ereuse_devicehub/resources/lot/views.py index 80922e47..6fd40282 100644 --- a/ereuse_devicehub/resources/lot/views.py +++ b/ereuse_devicehub/resources/lot/views.py @@ -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. diff --git a/ereuse_devicehub/resources/metric/definitions.py b/ereuse_devicehub/resources/metric/definitions.py index 4c90c77f..56f2dbdb 100644 --- a/ereuse_devicehub/resources/metric/definitions.py +++ b/ereuse_devicehub/resources/metric/definitions.py @@ -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): diff --git a/ereuse_devicehub/resources/metric/schema.py b/ereuse_devicehub/resources/metric/schema.py index 8bee7c81..70875ee9 100644 --- a/ereuse_devicehub/resources/metric/schema.py +++ b/ereuse_devicehub/resources/metric/schema.py @@ -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" + ) diff --git a/ereuse_devicehub/resources/metric/views.py b/ereuse_devicehub/resources/metric/views.py index 561150da..9c47964f 100644 --- a/ereuse_devicehub/resources/metric/views.py +++ b/ereuse_devicehub/resources/metric/views.py @@ -1,31 +1,38 @@ -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): def find(self, args: dict): metrics = { - "allocateds": self.allocated(), - "live": self.live(), + "allocateds": self.allocated(), + "live": self.live(), } return jsonify(metrics) def allocated(self): # TODO @cayop we need uncomment when the pr/83 is approved # return m.Device.query.filter(m.Device.allocated==True, owner==g.user).count() - return m.Device.query.filter(m.Device.allocated==True).count() + return m.Device.query.filter(m.Device.allocated == True).count() def live(self): # TODO @cayop we need uncomment when the pr/83 is approved # devices = m.Device.query.filter(m.Device.allocated==True, owner==g.user) - devices = m.Device.query.filter(m.Device.allocated==True) + devices = m.Device.query.filter(m.Device.allocated == True) count = 0 for dev in devices: live = allocate = None @@ -41,4 +48,3 @@ class MetricsView(View): count += 1 return count - diff --git a/ereuse_devicehub/resources/schemas.py b/ereuse_devicehub/resources/schemas.py index 0c632578..30ef7b24 100644 --- a/ereuse_devicehub/resources/schemas.py +++ b/ereuse_devicehub/resources/schemas.py @@ -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) diff --git a/ereuse_devicehub/resources/tag/__init__.py b/ereuse_devicehub/resources/tag/__init__.py index 2ad3eaa6..72b11706 100644 --- a/ereuse_devicehub/resources/tag/__init__.py +++ b/ereuse_devicehub/resources/tag/__init__.py @@ -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, - 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') + 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')) + 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), - 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), - 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), - view_func=device_view, - methods={'DELETE'}) + 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), + 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), + view_func=device_view, + 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, - id: str, - org: str = None, - owner: str = None, - sec: str = None, - provider: str = None): + def create_tag( + self, + id: str, + org: str = None, + owner: str = None, + sec: str = None, + provider: str = None, + ): """Create a tag with the given ID.""" - 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() @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() diff --git a/ereuse_devicehub/resources/tag/model.py b/ereuse_devicehub/resources/tag/model.py index 9c672f8c..4e29321c 100644 --- a/ereuse_devicehub/resources/tag/model.py +++ b/ereuse_devicehub/resources/tag/model.py @@ -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), - ForeignKey(User.id), - primary_key=True, - nullable=False, - default=lambda: g.user.id) + owner_id = Column( + UUID(as_uuid=True), + ForeignKey(User.id), + primary_key=True, + nullable=False, + default=lambda: g.user.id, + ) owner = relationship(User, primaryjoin=owner_id == User.id) - 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, - backref=backref('tags', lazy=True), - primaryjoin=Organization.id == org_id, - collection_class=set) + 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, + backref=backref('tags', lazy=True), + primaryjoin=Organization.id == org_id, + 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, - # 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, - backref=backref('tags', lazy=True, collection_class=Tags), - primaryjoin=Device.id == device_id) + 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, + backref=backref('tags', lazy=True, collection_class=Tags), + 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 diff --git a/ereuse_devicehub/resources/tag/schema.py b/ereuse_devicehub/resources/tag/schema.py index e1c8b608..a1d0df06 100644 --- a/ereuse_devicehub/resources/tag/schema.py +++ b/ereuse_devicehub/resources/tag/schema.py @@ -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') diff --git a/ereuse_devicehub/resources/tag/view.py b/ereuse_devicehub/resources/tag/view.py index 081383ae..f16d056e 100644 --- a/ereuse_devicehub/resources/tag/view.py +++ b/ereuse_devicehub/resources/tag/view.py @@ -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 diff --git a/ereuse_devicehub/resources/tradedocument/definitions.py b/ereuse_devicehub/resources/tradedocument/definitions.py index e321c7b4..94d0f44e 100644 --- a/ereuse_devicehub/resources/tradedocument/definitions.py +++ b/ereuse_devicehub/resources/tradedocument/definitions.py @@ -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 diff --git a/ereuse_devicehub/resources/tradedocument/models.py b/ereuse_devicehub/resources/tradedocument/models.py index 07af62d1..432b8c51 100644 --- a/ereuse_devicehub/resources/tradedocument/models.py +++ b/ereuse_devicehub/resources/tradedocument/models.py @@ -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, diff --git a/ereuse_devicehub/resources/tradedocument/schemas.py b/ereuse_devicehub/resources/tradedocument/schemas.py index 99aa9ab6..5de7a55f 100644 --- a/ereuse_devicehub/resources/tradedocument/schemas.py +++ b/ereuse_devicehub/resources/tradedocument/schemas.py @@ -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', - 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', - default='', - description=m.TradeDocument.file_name.comment, - validate=validate.Length(max=100)) - file_hash = SanitizedStr(data_key='hash', - default='', - description=m.TradeDocument.file_hash.comment, - validate=validate.Length(max=64)) + id_document = SanitizedStr( + data_key='documentId', + 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', + default='', + description=m.TradeDocument.file_name.comment, + validate=validate.Length(max=100), + ) + file_hash = SanitizedStr( + data_key='hash', + default='', + description=m.TradeDocument.file_hash.comment, + validate=validate.Length(max=64), + ) url = URL(description=m.TradeDocument.url.comment) lot = NestedOn('Lot', only_query='id', description=m.TradeDocument.lot.__doc__) trading = SanitizedStr(dump_only=True, description='') diff --git a/ereuse_devicehub/resources/tradedocument/views.py b/ereuse_devicehub/resources/tradedocument/views.py index a478da6f..5d71e73a 100644 --- a/ereuse_devicehub/resources/tradedocument/views.py +++ b/ereuse_devicehub/resources/tradedocument/views.py @@ -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() diff --git a/ereuse_devicehub/resources/user/__init__.py b/ereuse_devicehub/resources/user/__init__.py index 1bbe508b..13d99bee 100644 --- a/ereuse_devicehub/resources/user/__init__.py +++ b/ereuse_devicehub/resources/user/__init__.py @@ -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', - multiple=True, - 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.') + @option( + '-i', + '--inventory', + multiple=True, + 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.', + ) @option('-c', '--country', help='The country of the agent (if --agent is set).') @option('-t', '--telephone', help='The telephone of the agent (if --agent is set).') @option('-t', '--tax-id', help='The tax id of the agent (if --agent is set).') @option('-p', '--password', prompt=True, hide_input=True, confirmation_prompt=True) - def create_user(self, email: str, - password: str, - inventory: Iterable[str] = tuple(), - agent: str = None, - country: str = None, - telephone: str = None, - tax_id: str = None) -> dict: + def create_user( + self, + email: str, + password: str, + inventory: Iterable[str] = tuple(), + agent: str = None, + country: str = None, + telephone: str = None, + tax_id: str = None, + ) -> dict: """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() diff --git a/ereuse_devicehub/resources/user/models.py b/ereuse_devicehub/resources/user/models.py index 351907a8..4e51e92e 100644 --- a/ereuse_devicehub/resources/user/models.py +++ b/ereuse_devicehub/resources/user/models.py @@ -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): diff --git a/ereuse_devicehub/resources/user/schemas.py b/ereuse_devicehub/resources/user/schemas.py index a70c0da8..2e2cb725 100644 --- a/ereuse_devicehub/resources/user/schemas.py +++ b/ereuse_devicehub/resources/user/schemas.py @@ -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,27 +19,33 @@ 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, - description='Use this token in an Authorization header to access the app.' - 'The token can change overtime.') + token = String( + dump_only=True, + description='Use this token in an Authorization header to access the app.' + 'The token can change overtime.', + ) inventories = NestedOn(Inventory, many=True, dump_only=True) code = String(dump_only=True, description='Code of inactive accounts') - def __init__(self, - only=None, - exclude=('token',), - prefix='', - many=False, - context=None, - load_only=(), - dump_only=(), - partial=False): + def __init__( + self, + only=None, + exclude=('token',), + prefix='', + many=False, + context=None, + load_only=(), + dump_only=(), + 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): diff --git a/ereuse_devicehub/resources/user/views.py b/ereuse_devicehub/resources/user/views.py index 2fc8fc31..6aa9ddc0 100644 --- a/ereuse_devicehub/resources/user/views.py +++ b/ereuse_devicehub/resources/user/views.py @@ -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) diff --git a/ereuse_devicehub/resources/versions/versions.py b/ereuse_devicehub/resources/versions/versions.py index 78af7f6a..33e22fd8 100644 --- a/ereuse_devicehub/resources/versions/versions.py +++ b/ereuse_devicehub/resources/versions/versions.py @@ -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,18 +49,31 @@ class VersionDef(Resource): VIEW = None # We do not want to create default / documents endpoint AUTH = False - 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()): - super().__init__(app, import_name, static_folder, static_url_path, template_folder, - url_prefix, subdomain, url_defaults, root_path, cli_commands) + 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(), + ): + 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'} diff --git a/ereuse_devicehub/teal/__init__.py b/ereuse_devicehub/teal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ereuse_devicehub/teal/auth.py b/ereuse_devicehub/teal/auth.py new file mode 100644 index 00000000..14f1b404 --- /dev/null +++ b/ereuse_devicehub/teal/auth.py @@ -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] diff --git a/ereuse_devicehub/teal/cache.py b/ereuse_devicehub/teal/cache.py new file mode 100644 index 00000000..b6b59566 --- /dev/null +++ b/ereuse_devicehub/teal/cache.py @@ -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 diff --git a/ereuse_devicehub/teal/cli.py b/ereuse_devicehub/teal/cli.py new file mode 100644 index 00000000..21fc4ba8 --- /dev/null +++ b/ereuse_devicehub/teal/cli.py @@ -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 diff --git a/ereuse_devicehub/teal/client.py b/ereuse_devicehub/teal/client.py new file mode 100644 index 00000000..f18a1d53 --- /dev/null +++ b/ereuse_devicehub/teal/client.py @@ -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]) diff --git a/ereuse_devicehub/teal/config.py b/ereuse_devicehub/teal/config.py new file mode 100644 index 00000000..60500d1b --- /dev/null +++ b/ereuse_devicehub/teal/config.py @@ -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 `_ 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 + `_. + """ + + API_DOC_CONFIG_TITLE = 'Teal' + API_DOC_CONFIG_VERSION = '0.1' + """ + Configuration options for the api docs. They are the parameters + passed to `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 `_ + 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 + `_, + 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) diff --git a/ereuse_devicehub/teal/db.py b/ereuse_devicehub/teal/db.py new file mode 100644 index 00000000..08c02252 --- /dev/null +++ b/ereuse_devicehub/teal/db.py @@ -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 `_. + """ + + 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 `_. + """ + + 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 `_ + and `this issue `_. + """ + + 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 `_. + """ + 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 + ) diff --git a/ereuse_devicehub/teal/enums.py b/ereuse_devicehub/teal/enums.py new file mode 100644 index 00000000..175b2227 --- /dev/null +++ b/ereuse_devicehub/teal/enums.py @@ -0,0 +1,4421 @@ +from enum import Enum, unique + + +@unique +class Currency(Enum): + """Currencies as for ISO 4217.""" + + AFN = 1 + ARS = 2 + AWG = 3 + AUD = 4 + AZN = 5 + BSD = 6 + BBD = 7 + BDT = 8 + BYR = 9 + BZD = 10 + BMD = 11 + BOB = 12 + BAM = 13 + BWP = 14 + BGN = 15 + BRL = 16 + BND = 17 + KHR = 18 + CAD = 19 + KYD = 20 + CLP = 21 + CNY = 22 + COP = 23 + CRC = 24 + HRK = 25 + CUP = 26 + CZK = 27 + DKK = 28 + DOP = 29 + XCD = 30 + EGP = 31 + SVC = 32 + EEK = 33 + EUR = 34 + FKP = 35 + FJD = 36 + GHC = 37 + GIP = 38 + GTQ = 39 + GGP = 40 + GYD = 41 + HNL = 42 + HKD = 43 + HUF = 44 + ISK = 45 + INR = 46 + IDR = 47 + IRR = 48 + IMP = 49 + ILS = 50 + JMD = 51 + JPY = 52 + JEP = 53 + KZT = 54 + KPW = 55 + KRW = 56 + KGS = 57 + LAK = 58 + LVL = 59 + LBP = 60 + LRD = 61 + LTL = 62 + MKD = 63 + MYR = 64 + MUR = 65 + MXN = 66 + MNT = 67 + MZN = 68 + NAD = 69 + NPR = 70 + ANG = 71 + NZD = 72 + NIO = 73 + NGN = 74 + NOK = 75 + OMR = 76 + PKR = 77 + PAB = 78 + PYG = 79 + PEN = 80 + PHP = 81 + PLN = 82 + QAR = 83 + RON = 84 + RUB = 85 + SHP = 86 + SAR = 87 + RSD = 88 + SCR = 89 + SGD = 90 + SBD = 91 + SOS = 92 + ZAR = 93 + LKR = 94 + SEK = 95 + CHF = 96 + SRD = 97 + SYP = 98 + TWD = 99 + THB = 100 + TTD = 101 + TRY = 102 + TRL = 103 + TVD = 104 + UAH = 105 + GBP = 106 + USD = 107 + UYU = 108 + UZS = 109 + VEF = 110 + VND = 111 + YER = 112 + ZWD = 113 + + def __str__(self): + if self == Currency.EUR: + return '€' + else: + return self.name + + +@unique +class Continent(Enum): + """ + Continent codes. + + From `Data hub `_. + """ + + AF = 'Africa' + NA = 'North America' + OC = 'Oceania' + AN = 'Antartica' + AS = 'Asia' + EU = 'Europe' + SA = 'South America' + + +@unique +class Country(Enum): + """ + Countries as ISO 3166-1 alpha 2. + + Taken from table from `iso-3616-1 commit + 8e31d749b9ce331cfa50c280a29b04ae2d805b7e `_. + """ + + AF = "Afghanistan" + AX = "Ã…land Islands" + AL = "Albania" + DZ = "Algeria" + AS = "American Samoa" + AD = "Andorra" + AO = "Angola" + AI = "Anguilla" + AQ = "Antarctica" + AG = "Antigua and Barbuda" + AR = "Argentina" + AM = "Armenia" + AW = "Aruba" + AU = "Australia" + AT = "Austria" + AZ = "Azerbaijan" + BS = "Bahamas" + BH = "Bahrain" + BD = "Bangladesh" + BB = "Barbados" + BY = "Belarus" + BE = "Belgium" + BZ = "Belize" + BJ = "Benin" + BM = "Bermuda" + BT = "Bhutan" + BO = "Bolivia (Plurinational State of)" + BQ = "Bonaire, Sint Eustatius and Saba" + BA = "Bosnia and Herzegovina" + BW = "Botswana" + BV = "Bouvet Island" + BR = "Brazil" + IO = "British Indian Ocean Territory" + BN = "Brunei Darussalam" + BG = "Bulgaria" + BF = "Burkina Faso" + BI = "Burundi" + KH = "Cambodia" + CM = "Cameroon" + CA = "Canada" + CV = "Cabo Verde" + KY = "Cayman Islands" + CF = "Central African Republic" + TD = "Chad" + CL = "Chile" + CN = "China" + CX = "Christmas Island" + CC = "Cocos (Keeling) Islands" + CO = "Colombia" + KM = "Comoros" + CG = "Congo" + CD = "Congo (Democratic Republic of the)" + CK = "Cook Islands" + CR = "Costa Rica" + CI = "Côte d'Ivoire" + HR = "Croatia" + CU = "Cuba" + CW = "Curaçao" + CY = "Cyprus" + CZ = "Czech Republic" + DK = "Denmark" + DJ = "Djibouti" + DM = "Dominica" + DO = "Dominican Republic" + EC = "Ecuador" + EG = "Egypt" + SV = "El Salvador" + GQ = "Equatorial Guinea" + ER = "Eritrea" + EE = "Estonia" + ET = "Ethiopia" + FK = "Falkland Islands (Malvinas)" + FO = "Faroe Islands" + FJ = "Fiji" + FI = "Finland" + FR = "France" + GF = "French Guiana" + PF = "French Polynesia" + TF = "French Southern Territories" + GA = "Gabon" + GM = "Gambia" + GE = "Georgia" + DE = "Germany" + GH = "Ghana" + GI = "Gibraltar" + GR = "Greece" + GL = "Greenland" + GD = "Grenada" + GP = "Guadeloupe" + GU = "Guam" + GT = "Guatemala" + GG = "Guernsey" + GN = "Guinea" + GW = "Guinea-Bissau" + GY = "Guyana" + HT = "Haiti" + HM = "Heard Island and McDonald Islands" + VA = "Holy See" + HN = "Honduras" + HK = "Hong Kong" + HU = "Hungary" + IS = "Iceland" + IN = "India" + ID = "Indonesia" + IR = "Iran (Islamic Republic of)" + IQ = "Iraq" + IE = "Ireland" + IM = "Isle of Man" + IL = "Israel" + IT = "Italy" + JM = "Jamaica" + JP = "Japan" + JE = "Jersey" + JO = "Jordan" + KZ = "Kazakhstan" + KE = "Kenya" + KI = "Kiribati" + KP = "Korea (Democratic People's Republic of)" + KR = "Korea (Republic of)" + KW = "Kuwait" + KG = "Kyrgyzstan" + LA = "Lao People's Democratic Republic" + LV = "Latvia" + LB = "Lebanon" + LS = "Lesotho" + LR = "Liberia" + LY = "Libya" + LI = "Liechtenstein" + LT = "Lithuania" + LU = "Luxembourg" + MO = "Macao" + MK = "Macedonia (the former Yugoslav Republic of)" + MG = "Madagascar" + MW = "Malawi" + MY = "Malaysia" + MV = "Maldives" + ML = "Mali" + MT = "Malta" + MH = "Marshall Islands" + MQ = "Martinique" + MR = "Mauritania" + MU = "Mauritius" + YT = "Mayotte" + MX = "Mexico" + FM = "Micronesia (Federated States of)" + MD = "Moldova (Republic of)" + MC = "Monaco" + MN = "Mongolia" + ME = "Montenegro" + MS = "Montserrat" + MA = "Morocco" + MZ = "Mozambique" + MM = "Myanmar" + NA = "Namibia" + NR = "Nauru" + NP = "Nepal" + NL = "Netherlands" + NC = "New Caledonia" + NZ = "New Zealand" + NI = "Nicaragua" + NE = "Niger" + NG = "Nigeria" + NU = "Niue" + NF = "Norfolk Island" + MP = "Northern Mariana Islands" + NO = "Norway" + OM = "Oman" + PK = "Pakistan" + PW = "Palau" + PS = "Palestine, State of" + PA = "Panama" + PG = "Papua New Guinea" + PY = "Paraguay" + PE = "Peru" + PH = "Philippines" + PN = "Pitcairn" + PL = "Poland" + PT = "Portugal" + PR = "Puerto Rico" + QA = "Qatar" + RE = "Réunion" + RO = "Romania" + RU = "Russian Federation" + RW = "Rwanda" + BL = "Saint Barthélemy" + SH = "Saint Helena, Ascension and Tristan da Cunha" + KN = "Saint Kitts and Nevis" + LC = "Saint Lucia" + MF = "Saint Martin (French part)" + PM = "Saint Pierre and Miquelon" + VC = "Saint Vincent and the Grenadines" + WS = "Samoa" + SM = "San Marino" + ST = "Sao Tome and Principe" + SA = "Saudi Arabia" + SN = "Senegal" + RS = "Serbia" + SC = "Seychelles" + SL = "Sierra Leone" + SG = "Singapore" + SX = "Sint Maarten (Dutch part)" + SK = "Slovakia" + SI = "Slovenia" + SB = "Solomon Islands" + SO = "Somalia" + ZA = "South Africa" + GS = "South Georgia and the South Sandwich Islands" + SS = "South Sudan" + ES = "Spain" + LK = "Sri Lanka" + SD = "Sudan" + SR = "Suriname" + SJ = "Svalbard and Jan Mayen" + SZ = "Swaziland" + SE = "Sweden" + CH = "Switzerland" + SY = "Syrian Arab Republic" + TW = "Taiwan, Province of China" + TJ = "Tajikistan" + TZ = "Tanzania, United Republic of" + TH = "Thailand" + TL = "Timor-Leste" + TG = "Togo" + TK = "Tokelau" + TO = "Tonga" + TT = "Trinidad and Tobago" + TN = "Tunisia" + TR = "Turkey" + TM = "Turkmenistan" + TC = "Turks and Caicos Islands" + TV = "Tuvalu" + UG = "Uganda" + UA = "Ukraine" + AE = "United Arab Emirates" + GB = "United Kingdom of Great Britain and Northern Ireland" + US = "United States of America" + UM = "United States Minor Outlying Islands" + UY = "Uruguay" + UZ = "Uzbekistan" + VU = "Vanuatu" + VE = "Venezuela (Bolivarian Republic of)" + VN = "Viet Nam" + VG = "Virgin Islands (British)" + VI = "Virgin Islands (U.S.)" + WF = "Wallis and Futuna" + EH = "Western Sahara" + YE = "Yemen" + ZM = "Zambia" + ZW = "Zimbabwe" + + def __contains__(self, item: 'Subdivision'): + """Checks if a Subdivision is inside of this Country.""" + if not isinstance(item, Subdivision): + raise TypeError('Only subdivisions can be inside a country.') + return item.country == self + + def __str__(self): + return self.value + + +class SubdivisionMixin: + @property + def country(self: Enum) -> Country: + """Returns the Country of the Subdivision.""" + return Country[self.name[0:2]] + + +# noinspection PyArgumentList +Subdivision = Enum( + 'Subdivision', + type=SubdivisionMixin, + module=__name__, + names=( + 'AE-AJ', + 'AE-AZ', + 'AE-DU', + 'AE-FU', + 'AE-RK', + 'AE-SH', + 'AE-UQ', + 'AF-BAL', + 'AF-BAM', + 'AF-BDG', + 'AF-BDS', + 'AF-BGL', + 'AF-FRAU', + 'AF-FYB', + 'AF-GHA', + 'AF-GHO', + 'AF-HEL', + 'AF-HER', + 'AF-JOW', + 'AF-KAB', + 'AF-KANN', + 'AF-KAP', + 'AF-KDZ', + 'AF-KNR', + 'AF-LAG', + 'AF-LOW', + 'AF-NAN', + 'AF-NIM', + 'AF-ORU', + 'AF-PAR', + 'AF-PIA', + 'AF-PKA', + 'AF-SAM', + 'AF-SAR', + 'AF-TAK', + 'AF-WAR', + 'AF-ZAB', + 'AL-BR', + 'AL-BU', + 'AL-DI', + 'AL-DL', + 'AL-DR', + 'AL-DV', + 'AL-EL', + 'AL-ER', + 'AL-FR', + 'AL-GJ', + 'AL-GR', + 'AL-HA', + 'AL-KA', + 'AL-KB', + 'AL-KC', + 'AL-KO', + 'AL-KR', + 'AL-KU', + 'AL-LA', + 'AL-LB', + 'AL-LE', + 'AL-LU', + 'AL-MK', + 'AL-MM', + 'AL-MR', + 'AL-MT', + 'AL-PG', + 'AL-PQ', + 'AL-PR', + 'AL-PU', + 'AL-SH', + 'AL-SK', + 'AL-SR', + 'AL-TE', + 'AL-TP', + 'AL-TR', + 'AL-VL', + 'AM-AG', + 'AM-AR', + 'AM-AV', + 'AM-ER', + 'AM-GR', + 'AM-KT', + 'AM-LO', + 'AM-SH', + 'AM-SU', + 'AM-TV', + 'AM-VD', + 'AO-BGO', + 'AO-BGU', + 'AO-BIE', + 'AO-CAB', + 'AO-CCU', + 'AO-CNN', + 'AO-CNO', + 'AO-CUS', + 'AO-HUA', + 'AO-HUI', + 'AO-LNO', + 'AO-LSU', + 'AO-LUA', + 'AO-MAL', + 'AO-MOX', + 'AO-NAM', + 'AO-UIG', + 'AO-ZAI', + 'AR-A', + 'AR-B', + 'AR-C', + 'AR-D', + 'AR-E', + 'AR-F', + 'AR-G', + 'AR-H', + 'AR-J', + 'AR-K', + 'AR-L', + 'AR-M', + 'AR-N', + 'AR-P', + 'AR-Q', + 'AR-R', + 'AR-S', + 'AR-T', + 'AR-U', + 'AR-V', + 'AR-W', + 'AR-X', + 'AR-Y', + 'AR-Z', + 'AT-1', + 'AT-2', + 'AT-3', + 'AT-4', + 'AT-5', + 'AT-6', + 'AT-7', + 'AT-8', + 'AT-9', + 'AU-CT', + 'AU-NS', + 'AU-NT', + 'AU-QL', + 'AU-SA', + 'AU-TS', + 'AU-VI', + 'AU-WA', + 'AZ-AB', + 'AZ-ABS', + 'AZ-AGA', + 'AZ-AGC', + 'AZ-AGM', + 'AZ-AGS', + 'AZ-AGU', + 'AZ-AST', + 'AZ-BA', + 'AZ-BAB', + 'AZ-BAL', + 'AZ-BAR', + 'AZ-BEY', + 'AZ-BIL', + 'AZ-CAB', + 'AZ-CAL', + 'AZ-CUL', + 'AZ-DAS', + 'AZ-DAV', + 'AZ-FUZ', + 'AZ-GA', + 'AZ-GAD', + 'AZ-GOR', + 'AZ-GOY', + 'AZ-HAC', + 'AZ-IMI', + 'AZ-ISM', + 'AZ-KAL', + 'AZ-KUR', + 'AZ-LA', + 'AZ-LAC', + 'AZ-LAN', + 'AZ-LER', + 'AZ-MAS', + 'AZ-MI', + 'AZ-MM', + 'AZ-NA', + 'AZ-NEF', + 'AZ-OGU', + 'AZ-ORD', + 'AZ-QAB', + 'AZ-QAX', + 'AZ-QAZ', + 'AZ-QBA', + 'AZ-QBI', + 'AZ-QOB', + 'AZ-QUS', + 'AZ-SA', + 'AZ-SAB', + 'AZ-SAD', + 'AZ-SAH', + 'AZ-SAK', + 'AZ-SAL', + 'AZ-SAR', + 'AZ-SAT', + 'AZ-SIY', + 'AZ-SKR', + 'AZ-SM', + 'AZ-SMI', + 'AZ-SMX', + 'AZ-SS', + 'AZ-SUS', + 'AZ-TAR', + 'AZ-TOV', + 'AZ-UCA', + 'AZ-XA', + 'AZ-XAC', + 'AZ-XAN', + 'AZ-XCI', + 'AZ-XIZ', + 'AZ-XVD', + 'AZ-YAR', + 'AZ-YE', + 'AZ-YEV', + 'AZ-ZAN', + 'AZ-ZAQ', + 'AZ-ZAR', + 'BA-BIH', + 'BA-SRP', + 'BD-01', + 'BD-02', + 'BD-03', + 'BD-04', + 'BD-05', + 'BD-06', + 'BD-07', + 'BD-08', + 'BD-09', + 'BD-1', + 'BD-10', + 'BD-11', + 'BD-12', + 'BD-13', + 'BD-14', + 'BD-15', + 'BD-16', + 'BD-17', + 'BD-18', + 'BD-19', + 'BD-2', + 'BD-20', + 'BD-21', + 'BD-22', + 'BD-23', + 'BD-24', + 'BD-25', + 'BD-26', + 'BD-27', + 'BD-28', + 'BD-29', + 'BD-3', + 'BD-30', + 'BD-31', + 'BD-32', + 'BD-33', + 'BD-34', + 'BD-35', + 'BD-36', + 'BD-37', + 'BD-38', + 'BD-39', + 'BD-4', + 'BD-40', + 'BD-41', + 'BD-42', + 'BD-43', + 'BD-44', + 'BD-45', + 'BD-46', + 'BD-47', + 'BD-48', + 'BD-49', + 'BD-5', + 'BD-50', + 'BD-51', + 'BD-52', + 'BD-53', + 'BD-54', + 'BD-55', + 'BD-56', + 'BD-57', + 'BD-58', + 'BD-59', + 'BD-6', + 'BD-60', + 'BD-61', + 'BD-62', + 'BD-63', + 'BD-64', + 'BE-BRU', + 'BE-VAN', + 'BE-VBR', + 'BE-VLG', + 'BE-VLI', + 'BE-VOV', + 'BE-VWV', + 'BE-WAL', + 'BE-WBR', + 'BE-WHT', + 'BE-WLG', + 'BE-WLX', + 'BE-WNA', + 'BF-BAL', + 'BF-BAM', + 'BF-BAN', + 'BF-BAZ', + 'BF-BGR', + 'BF-BLG', + 'BF-BLK', + 'BF-COM', + 'BF-GAN', + 'BF-GNA', + 'BF-GOU', + 'BF-HOU', + 'BF-IOB', + 'BF-KAD', + 'BF-KEN', + 'BF-KMD', + 'BF-KMP', + 'BF-KOP', + 'BF-KOS', + 'BF-KOT', + 'BF-KOW', + 'BF-LER', + 'BF-LOR', + 'BF-MOU', + 'BF-NAM', + 'BF-NAO', + 'BF-NAY', + 'BF-NOU', + 'BF-OUB', + 'BF-OUD', + 'BF-PAS', + 'BF-PON', + 'BF-SEN', + 'BF-SIS', + 'BF-SMT', + 'BF-SNG', + 'BF-SOM', + 'BF-SOR', + 'BF-TAP', + 'BF-TUI', + 'BF-YAG', + 'BF-YAT', + 'BF-ZIR', + 'BF-ZON', + 'BF-ZOU', + 'BG-01', + 'BG-02', + 'BG-03', + 'BG-04', + 'BG-05', + 'BG-06', + 'BG-07', + 'BG-08', + 'BG-09', + 'BG-10', + 'BG-11', + 'BG-12', + 'BG-13', + 'BG-14', + 'BG-15', + 'BG-16', + 'BG-17', + 'BG-18', + 'BG-19', + 'BG-20', + 'BG-21', + 'BG-22', + 'BG-23', + 'BG-24', + 'BG-25', + 'BG-26', + 'BG-27', + 'BG-28', + 'BH-01', + 'BH-02', + 'BH-03', + 'BH-04', + 'BH-05', + 'BH-06', + 'BH-07', + 'BH-08', + 'BH-09', + 'BH-10', + 'BH-11', + 'BH-12', + 'BI-BB', + 'BI-BJ', + 'BI-BR', + 'BI-CA', + 'BI-CI', + 'BI-GI', + 'BI-KI', + 'BI-KR', + 'BI-KY', + 'BI-MA', + 'BI-MU', + 'BI-MW', + 'BI-MY', + 'BI-NG', + 'BI-RT', + 'BI-RY', + 'BJ-AK', + 'BJ-AL', + 'BJ-AQ', + 'BJ-BO', + 'BJ-CO', + 'BJ-DO', + 'BJ-KO', + 'BJ-LI', + 'BJ-MO', + 'BJ-OU', + 'BJ-PL', + 'BJ-ZO', + 'BN-BE', + 'BN-BM', + 'BN-TE', + 'BN-TU', + 'BO-B', + 'BO-C', + 'BO-H', + 'BO-L', + 'BO-N', + 'BO-O', + 'BO-P', + 'BO-S', + 'BO-T', + 'BR-AC', + 'BR-AL', + 'BR-AM', + 'BR-AP', + 'BR-BA', + 'BR-CE', + 'BR-DF', + 'BR-ES', + 'BR-GO', + 'BR-MA', + 'BR-MG', + 'BR-MS', + 'BR-MT', + 'BR-PA', + 'BR-PB', + 'BR-PE', + 'BR-PI', + 'BR-PR', + 'BR-RJ', + 'BR-RN', + 'BR-RO', + 'BR-RR', + 'BR-RS', + 'BR-SC', + 'BR-SE', + 'BR-SP', + 'BR-TO', + 'BS-AC', + 'BS-BI', + 'BS-CI', + 'BS-EX', + 'BS-FC', + 'BS-FP', + 'BS-GH', + 'BS-GT', + 'BS-HI', + 'BS-HR', + 'BS-IN', + 'BS-KB', + 'BS-LI', + 'BS-MG', + 'BS-MH', + 'BS-NB', + 'BS-NP', + 'BS-RI', + 'BS-RS', + 'BS-SP', + 'BS-SR', + 'BT-11', + 'BT-12', + 'BT-13', + 'BT-14', + 'BT-15', + 'BT-21', + 'BT-22', + 'BT-23', + 'BT-24', + 'BT-31', + 'BT-32', + 'BT-33', + 'BT-34', + 'BT-41', + 'BT-42', + 'BT-43', + 'BT-44', + 'BT-45', + 'BT-GA', + 'BT-TY', + 'BW-CE', + 'BW-CH', + 'BW-GH', + 'BW-KG', + 'BW-KL', + 'BW-KW', + 'BW-NE', + 'BW-NG', + 'BW-SE', + 'BW-SO', + 'BY-BR', + 'BY-HO', + 'BY-HR', + 'BY-MA', + 'BY-MI', + 'BY-VI', + 'BZ-BZ', + 'BZ-CY', + 'BZ-CZL', + 'BZ-OW', + 'BZ-SC', + 'BZ-TOL', + 'CA-AB', + 'CA-BC', + 'CA-MB', + 'CA-NB', + 'CA-NL', + 'CA-NS', + 'CA-NT', + 'CA-NU', + 'CA-ON', + 'CA-PE', + 'CA-QC', + 'CA-SK', + 'CA-YT', + 'CD-BC', + 'CD-BN', + 'CD-EQ', + 'CD-KA', + 'CD-KE', + 'CD-KN', + 'CD-KW', + 'CD-MA', + 'CD-NK', + 'CD-OR', + 'CD-SK', + 'CF-AC', + 'CF-BB', + 'CF-BGF', + 'CF-BK', + 'CF-HK', + 'CF-HM', + 'CF-HS', + 'CF-KB', + 'CF-KG', + 'CF-LB', + 'CF-MB', + 'CF-MP', + 'CF-NM', + 'CF-OP', + 'CF-SE', + 'CF-UK', + 'CF-VK', + 'CG-11', + 'CG-12', + 'CG-13', + 'CG-14', + 'CG-15', + 'CG-2', + 'CG-5', + 'CG-7', + 'CG-8', + 'CG-9', + 'CG-BZV', + 'CH-AG', + 'CH-AI', + 'CH-AR', + 'CH-BE', + 'CH-BL', + 'CH-BS', + 'CH-FR', + 'CH-GE', + 'CH-GL', + 'CH-GR', + 'CH-JU', + 'CH-LU', + 'CH-NE', + 'CH-NW', + 'CH-OW', + 'CH-SG', + 'CH-SH', + 'CH-SO', + 'CH-SZ', + 'CH-TG', + 'CH-TI', + 'CH-UR', + 'CH-VD', + 'CH-VS', + 'CH-ZG', + 'CH-ZH', + 'CI-01', + 'CI-02', + 'CI-03', + 'CI-04', + 'CI-05', + 'CI-06', + 'CI-07', + 'CI-08', + 'CI-09', + 'CI-10', + 'CI-11', + 'CI-12', + 'CI-13', + 'CI-14', + 'CI-15', + 'CI-16', + 'CL-AI', + 'CL-AN', + 'CL-AR', + 'CL-AT', + 'CL-BI', + 'CL-CO', + 'CL-LI', + 'CL-LL', + 'CL-MA', + 'CL-ML', + 'CL-RM', + 'CL-TA', + 'CL-VS', + 'CM-AD', + 'CM-CE', + 'CM-EN', + 'CM-ES', + 'CM-LT', + 'CM-NO', + 'CM-NW', + 'CM-OU', + 'CM-SU', + 'CM-SW', + 'CN-11', + 'CN-12', + 'CN-13', + 'CN-14', + 'CN-15', + 'CN-21', + 'CN-22', + 'CN-23', + 'CN-31', + 'CN-32', + 'CN-33', + 'CN-34', + 'CN-35', + 'CN-36', + 'CN-37', + 'CN-41', + 'CN-42', + 'CN-43', + 'CN-44', + 'CN-45', + 'CN-46', + 'CN-50', + 'CN-51', + 'CN-52', + 'CN-53', + 'CN-54', + 'CN-61', + 'CN-62', + 'CN-63', + 'CN-64', + 'CN-65', + 'CN-71', + 'CN-91', + 'CN-92', + 'CO-AMA', + 'CO-ANT', + 'CO-ARA', + 'CO-ATL', + 'CO-BOL', + 'CO-BOY', + 'CO-CAL', + 'CO-CAQ', + 'CO-CAS', + 'CO-CAU', + 'CO-CES', + 'CO-CHO', + 'CO-COR', + 'CO-CUN', + 'CO-DC', + 'CO-GUA', + 'CO-GUV', + 'CO-HUI', + 'CO-LAG', + 'CO-MAG', + 'CO-MET', + 'CO-NAR', + 'CO-NSA', + 'CO-PUT', + 'CO-QUI', + 'CO-RIS', + 'CO-SAN', + 'CO-SAP', + 'CO-SUC', + 'CO-TOL', + 'CO-VAC', + 'CO-VAU', + 'CO-VID', + 'CR-A', + 'CR-C', + 'CR-G', + 'CR-H', + 'CR-L', + 'CR-P', + 'CR-SJ', + 'CU-01', + 'CU-02', + 'CU-03', + 'CU-04', + 'CU-05', + 'CU-06', + 'CU-07', + 'CU-08', + 'CU-09', + 'CU-10', + 'CU-11', + 'CU-12', + 'CU-13', + 'CU-14', + 'CU-99', + 'CV-B', + 'CV-BR', + 'CV-BV', + 'CV-CA', + 'CV-CR', + 'CV-CS', + 'CV-FO', + 'CV-MA', + 'CV-MO', + 'CV-PA', + 'CV-PN', + 'CV-PR', + 'CV-RG', + 'CV-S', + 'CV-SF', + 'CV-SL', + 'CV-SN', + 'CV-SV', + 'CV-TA', + 'CY-01', + 'CY-02', + 'CY-03', + 'CY-04', + 'CY-05', + 'CY-06', + 'CZ-JC', + 'CZ-JM', + 'CZ-KA', + 'CZ-KR', + 'CZ-LI', + 'CZ-MO', + 'CZ-OL', + 'CZ-PA', + 'CZ-PL', + 'CZ-PR', + 'CZ-ST', + 'CZ-US', + 'CZ-VY', + 'CZ-ZL', + 'DE-BB', + 'DE-BE', + 'DE-BW', + 'DE-BY', + 'DE-HB', + 'DE-HE', + 'DE-HH', + 'DE-MV', + 'DE-NI', + 'DE-NW', + 'DE-RP', + 'DE-SH', + 'DE-SL', + 'DE-SN', + 'DE-ST', + 'DE-TH', + 'DJ-AS', + 'DJ-DI', + 'DJ-DJ', + 'DJ-OB', + 'DJ-TA', + 'DK-015', + 'DK-020', + 'DK-025', + 'DK-030', + 'DK-035', + 'DK-040', + 'DK-042', + 'DK-050', + 'DK-055', + 'DK-060', + 'DK-065', + 'DK-070', + 'DK-076', + 'DK-080', + 'DK-101', + 'DK-147', + 'DO-01', + 'DO-02', + 'DO-03', + 'DO-04', + 'DO-05', + 'DO-06', + 'DO-07', + 'DO-08', + 'DO-09', + 'DO-10', + 'DO-11', + 'DO-12', + 'DO-13', + 'DO-14', + 'DO-15', + 'DO-16', + 'DO-17', + 'DO-18', + 'DO-19', + 'DO-20', + 'DO-21', + 'DO-22', + 'DO-23', + 'DO-24', + 'DO-25', + 'DO-26', + 'DO-27', + 'DO-28', + 'DO-29', + 'DO-30', + 'DZ-01', + 'DZ-02', + 'DZ-03', + 'DZ-04', + 'DZ-05', + 'DZ-06', + 'DZ-07', + 'DZ-08', + 'DZ-09', + 'DZ-10', + 'DZ-11', + 'DZ-12', + 'DZ-13', + 'DZ-14', + 'DZ-15', + 'DZ-16', + 'DZ-17', + 'DZ-18', + 'DZ-19', + 'DZ-20', + 'DZ-21', + 'DZ-22', + 'DZ-23', + 'DZ-24', + 'DZ-25', + 'DZ-26', + 'DZ-27', + 'DZ-28', + 'DZ-29', + 'DZ-30', + 'DZ-31', + 'DZ-32', + 'DZ-33', + 'DZ-34', + 'DZ-35', + 'DZ-36', + 'DZ-37', + 'DZ-38', + 'DZ-39', + 'DZ-40', + 'DZ-41', + 'DZ-42', + 'DZ-43', + 'DZ-44', + 'DZ-45', + 'DZ-46', + 'DZ-47', + 'DZ-48', + 'EC-A', + 'EC-B', + 'EC-C', + 'EC-D', + 'EC-E', + 'EC-F', + 'EC-G', + 'EC-H', + 'EC-I', + 'EC-L', + 'EC-M', + 'EC-N', + 'EC-O', + 'EC-P', + 'EC-R', + 'EC-S', + 'EC-T', + 'EC-U', + 'EC-W', + 'EC-X', + 'EC-Y', + 'EC-Z', + 'EE-37', + 'EE-39', + 'EE-44', + 'EE-49', + 'EE-51', + 'EE-57', + 'EE-59', + 'EE-65', + 'EE-67', + 'EE-70', + 'EE-74', + 'EE-78', + 'EE-82', + 'EE-84', + 'EE-86', + 'EG-ALX', + 'EG-ASN', + 'EG-AST', + 'EG-BA', + 'EG-BH', + 'EG-BNS', + 'EG-C', + 'EG-DK', + 'EG-DT', + 'EG-FYM', + 'EG-GH', + 'EG-GZ', + 'EG-IS', + 'EG-JS', + 'EG-KB', + 'EG-KFS', + 'EG-KN', + 'EG-MN', + 'EG-MNF', + 'EG-MT', + 'EG-PTS', + 'EG-SHG', + 'EG-SHR', + 'EG-SIN', + 'EG-SUZ', + 'EG-WAD', + 'ER-AN', + 'ER-DK', + 'ER-DU', + 'ER-GB', + 'ER-MA', + 'ER-SK', + 'ES-A', + 'ES-AB', + 'ES-AL', + 'ES-AN', + 'ES-AR', + 'ES-AV', + 'ES-B', + 'ES-BA', + 'ES-BI', + 'ES-BU', + 'ES-C', + 'ES-CA', + 'ES-CC', + 'ES-CE', + 'ES-CL', + 'ES-CM', + 'ES-CN', + 'ES-CO', + 'ES-CR', + 'ES-CS', + 'ES-CT', + 'ES-CU', + 'ES-EX', + 'ES-GA', + 'ES-GC', + 'ES-GI', + 'ES-GR', + 'ES-GU', + 'ES-H', + 'ES-HU', + 'ES-J', + 'ES-L', + 'ES-LE', + 'ES-LO', + 'ES-LU', + 'ES-M', + 'ES-MA', + 'ES-ML', + 'ES-MU', + 'ES-NA', + 'ES-O', + 'ES-OR', + 'ES-P', + 'ES-PM', + 'ES-PO', + 'ES-PV', + 'ES-S', + 'ES-SA', + 'ES-SE', + 'ES-SG', + 'ES-SO', + 'ES-SS', + 'ES-T', + 'ES-TE', + 'ES-TF', + 'ES-TO', + 'ES-V', + 'ES-VA', + 'ES-VC', + 'ES-VI', + 'ES-Z', + 'ES-ZA', + 'ET-AA', + 'ET-AF', + 'ET-AM', + 'ET-BE', + 'ET-DD', + 'ET-GA', + 'ET-HA', + 'ET-OR', + 'ET-SN', + 'ET-SO', + 'ET-TI', + 'FI-AL', + 'FI-ES', + 'FI-IS', + 'FI-LL', + 'FI-LS', + 'FI-OL', + 'FJ-C', + 'FJ-E', + 'FJ-N', + 'FJ-R', + 'FJ-W', + 'FM-KSA', + 'FM-PNI', + 'FM-TRK', + 'FM-YAP', + 'FR-01', + 'FR-02', + 'FR-03', + 'FR-04', + 'FR-05', + 'FR-06', + 'FR-07', + 'FR-08', + 'FR-09', + 'FR-10', + 'FR-11', + 'FR-12', + 'FR-13', + 'FR-14', + 'FR-15', + 'FR-16', + 'FR-17', + 'FR-18', + 'FR-19', + 'FR-21', + 'FR-22', + 'FR-23', + 'FR-24', + 'FR-25', + 'FR-26', + 'FR-27', + 'FR-28', + 'FR-29', + 'FR-2A', + 'FR-2B', + 'FR-30', + 'FR-31', + 'FR-32', + 'FR-33', + 'FR-34', + 'FR-35', + 'FR-36', + 'FR-37', + 'FR-38', + 'FR-39', + 'FR-40', + 'FR-41', + 'FR-42', + 'FR-43', + 'FR-44', + 'FR-45', + 'FR-46', + 'FR-47', + 'FR-48', + 'FR-49', + 'FR-50', + 'FR-51', + 'FR-52', + 'FR-53', + 'FR-54', + 'FR-55', + 'FR-56', + 'FR-57', + 'FR-58', + 'FR-59', + 'FR-60', + 'FR-61', + 'FR-62', + 'FR-63', + 'FR-64', + 'FR-65', + 'FR-66', + 'FR-67', + 'FR-68', + 'FR-69', + 'FR-70', + 'FR-71', + 'FR-72', + 'FR-73', + 'FR-74', + 'FR-75', + 'FR-76', + 'FR-77', + 'FR-78', + 'FR-79', + 'FR-80', + 'FR-81', + 'FR-82', + 'FR-83', + 'FR-84', + 'FR-85', + 'FR-86', + 'FR-87', + 'FR-88', + 'FR-89', + 'FR-90', + 'FR-91', + 'FR-92', + 'FR-93', + 'FR-94', + 'FR-95', + 'FR-A', + 'FR-B', + 'FR-C', + 'FR-D', + 'FR-E', + 'FR-F', + 'FR-G', + 'FR-GF', + 'FR-GP', + 'FR-H', + 'FR-I', + 'FR-J', + 'FR-K', + 'FR-L', + 'FR-M', + 'FR-MQ', + 'FR-N', + 'FR-NC', + 'FR-O', + 'FR-P', + 'FR-PF', + 'FR-PM', + 'FR-Q', + 'FR-R', + 'FR-RE', + 'FR-S', + 'FR-T', + 'FR-TF', + 'FR-U', + 'FR-V', + 'FR-WF', + 'FR-YT', + 'GA-1', + 'GA-2', + 'GA-3', + 'GA-4', + 'GA-5', + 'GA-6', + 'GA-7', + 'GA-8', + 'GA-9', + 'GB-ABD', + 'GB-ABE', + 'GB-AGB', + 'GB-AGY', + 'GB-ANS', + 'GB-ANT', + 'GB-ARD', + 'GB-ARM', + 'GB-BAS', + 'GB-BBD', + 'GB-BDF', + 'GB-BDG', + 'GB-BEN', + 'GB-BEX', + 'GB-BFS', + 'GB-BGE', + 'GB-BGW', + 'GB-BIR', + 'GB-BKM', + 'GB-BLA', + 'GB-BLY', + 'GB-BMH', + 'GB-BNB', + 'GB-BNE', + 'GB-BNH', + 'GB-BNS', + 'GB-BOL', + 'GB-BPL', + 'GB-BRC', + 'GB-BRD', + 'GB-BRY', + 'GB-BST', + 'GB-BUR', + 'GB-CAM', + 'GB-CAY', + 'GB-CGN', + 'GB-CGV', + 'GB-CHA', + 'GB-CHS', + 'GB-CKF', + 'GB-CKT', + 'GB-CLD', + 'GB-CLK', + 'GB-CLR', + 'GB-CMA', + 'GB-CMD', + 'GB-CMN', + 'GB-CON', + 'GB-COV', + 'GB-CRF', + 'GB-CRY', + 'GB-CSR', + 'GB-CWY', + 'GB-DAL', + 'GB-DBY', + 'GB-DEN', + 'GB-DER', + 'GB-DEV', + 'GB-DGN', + 'GB-DGY', + 'GB-DNC', + 'GB-DND', + 'GB-DOR', + 'GB-DOW', + 'GB-DRY', + 'GB-DUD', + 'GB-DUR', + 'GB-EAL', + 'GB-EAW', + 'GB-EAY', + 'GB-EDH', + 'GB-EDU', + 'GB-ELN', + 'GB-ELS', + 'GB-ENF', + 'GB-ENG', + 'GB-ERW', + 'GB-ERY', + 'GB-ESS', + 'GB-ESX', + 'GB-FAL', + 'GB-FER', + 'GB-FIF', + 'GB-FLN', + 'GB-GAT', + 'GB-GBN', + 'GB-GLG', + 'GB-GLS', + 'GB-GRE', + 'GB-GSY', + 'GB-GWN', + 'GB-HAL', + 'GB-HAM', + 'GB-HAV', + 'GB-HCK', + 'GB-HEF', + 'GB-HIL', + 'GB-HLD', + 'GB-HMF', + 'GB-HNS', + 'GB-HPL', + 'GB-HRT', + 'GB-HRW', + 'GB-HRY', + 'GB-IOM', + 'GB-IOS', + 'GB-IOW', + 'GB-ISL', + 'GB-IVC', + 'GB-JSY', + 'GB-KEC', + 'GB-KEN', + 'GB-KHL', + 'GB-KIR', + 'GB-KTT', + 'GB-KWL', + 'GB-LAN', + 'GB-LBH', + 'GB-LCE', + 'GB-LDS', + 'GB-LEC', + 'GB-LEW', + 'GB-LIN', + 'GB-LIV', + 'GB-LMV', + 'GB-LND', + 'GB-LRN', + 'GB-LSB', + 'GB-LUT', + 'GB-MAN', + 'GB-MDB', + 'GB-MDW', + 'GB-MFT', + 'GB-MIK', + 'GB-MLN', + 'GB-MON', + 'GB-MRT', + 'GB-MRY', + 'GB-MTY', + 'GB-MYL', + 'GB-NAY', + 'GB-NBL', + 'GB-NDN', + 'GB-NEL', + 'GB-NET', + 'GB-NFK', + 'GB-NGM', + 'GB-NIR', + 'GB-NLK', + 'GB-NLN', + 'GB-NSM', + 'GB-NTA', + 'GB-NTH', + 'GB-NTL', + 'GB-NTT', + 'GB-NTY', + 'GB-NWM', + 'GB-NWP', + 'GB-NYK', + 'GB-NYM', + 'GB-OLD', + 'GB-OMH', + 'GB-ORK', + 'GB-OXF', + 'GB-PEM', + 'GB-PKN', + 'GB-PLY', + 'GB-POL', + 'GB-POR', + 'GB-POW', + 'GB-PTE', + 'GB-RCC', + 'GB-RCH', + 'GB-RCT', + 'GB-RDB', + 'GB-RDG', + 'GB-RFW', + 'GB-RIC', + 'GB-ROT', + 'GB-RUT', + 'GB-SAW', + 'GB-SAY', + 'GB-SCB', + 'GB-SCT', + 'GB-SFK', + 'GB-SFT', + 'GB-SGC', + 'GB-SHF', + 'GB-SHN', + 'GB-SHR', + 'GB-SKP', + 'GB-SLF', + 'GB-SLG', + 'GB-SLK', + 'GB-SND', + 'GB-SOL', + 'GB-SOM', + 'GB-SOS', + 'GB-SRY', + 'GB-STB', + 'GB-STE', + 'GB-STG', + 'GB-STH', + 'GB-STN', + 'GB-STS', + 'GB-STT', + 'GB-STY', + 'GB-SWA', + 'GB-SWD', + 'GB-SWK', + 'GB-TAM', + 'GB-TFW', + 'GB-THR', + 'GB-TOB', + 'GB-TOF', + 'GB-TRF', + 'GB-TWH', + 'GB-UKM', + 'GB-VGL', + 'GB-WAR', + 'GB-WBK', + 'GB-WDU', + 'GB-WFT', + 'GB-WGN', + 'GB-WILL', + 'GB-WKF', + 'GB-WLL', + 'GB-WLN', + 'GB-WLS', + 'GB-WLV', + 'GB-WND', + 'GB-WNM', + 'GB-WOK', + 'GB-WOR', + 'GB-WRL', + 'GB-WRT', + 'GB-WRX', + 'GB-WSM', + 'GB-WSX', + 'GB-YOR', + 'GB-ZET', + 'GE-AB', + 'GE-AJ', + 'GE-GU', + 'GE-IM', + 'GE-KA', + 'GE-KK', + 'GE-MM', + 'GE-RL', + 'GE-SJ', + 'GE-SK', + 'GE-SZ', + 'GE-TB', + 'GH-AA', + 'GH-AH', + 'GH-BA', + 'GH-CP', + 'GH-EP', + 'GH-NP', + 'GH-TV', + 'GH-UE', + 'GH-UW', + 'GH-WP', + 'GM-B', + 'GM-L', + 'GM-M', + 'GM-N', + 'GM-U', + 'GM-W', + 'GN-B', + 'GN-BE', + 'GN-BF', + 'GN-BK', + 'GN-C', + 'GN-CO', + 'GN-D', + 'GN-DB', + 'GN-DI', + 'GN-DL', + 'GN-DU', + 'GN-F', + 'GN-FA', + 'GN-FO', + 'GN-FR', + 'GN-GA', + 'GN-GU', + 'GN-K', + 'GN-KA', + 'GN-KB', + 'GN-KD; 2', + 'GN-KE', + 'GN-KN', + 'GN-KO', + 'GN-KS', + 'GN-L', + 'GN-LA', + 'GN-LE', + 'GN-LO', + 'GN-M', + 'GN-MC', + 'GN-MD', + 'GN-ML', + 'GN-MM', + 'GN-N', + 'GN-NZ', + 'GN-PI', + 'GN-SI', + 'GN-TE', + 'GN-TO', + 'GN-YO', + 'GQ-AN', + 'GQ-BN', + 'GQ-BS', + 'GQ-C', + 'GQ-CS', + 'GQ-I', + 'GQ-KN', + 'GQ-LI', + 'GQ-WN', + 'GR-01', + 'GR-03', + 'GR-04', + 'GR-05', + 'GR-06', + 'GR-07', + 'GR-11', + 'GR-12', + 'GR-13', + 'GR-14', + 'GR-15', + 'GR-16', + 'GR-17', + 'GR-21', + 'GR-22', + 'GR-23', + 'GR-24', + 'GR-31', + 'GR-32', + 'GR-33', + 'GR-34', + 'GR-41', + 'GR-42', + 'GR-43', + 'GR-44', + 'GR-51', + 'GR-52', + 'GR-53', + 'GR-54', + 'GR-55', + 'GR-56', + 'GR-57', + 'GR-58', + 'GR-59', + 'GR-61', + 'GR-62', + 'GR-63', + 'GR-64', + 'GR-69', + 'GR-71', + 'GR-72', + 'GR-73', + 'GR-81', + 'GR-82', + 'GR-83', + 'GR-84', + 'GR-85', + 'GR-91', + 'GR-92', + 'GR-93', + 'GR-94', + 'GR-A1', + 'GR-I', + 'GR-II', + 'GR-III', + 'GR-IV', + 'GR-IX', + 'GR-V', + 'GR-VI', + 'GR-VII', + 'GR-VIII', + 'GR-X', + 'GR-XI', + 'GR-XII', + 'GR-XIII', + 'GT-AV', + 'GT-BV', + 'GT-CM', + 'GT-CQ', + 'GT-ES', + 'GT-GU', + 'GT-HU', + 'GT-IZ', + 'GT-JA', + 'GT-JU', + 'GT-PE', + 'GT-PR', + 'GT-QC', + 'GT-QZ', + 'GT-RE', + 'GT-SA', + 'GT-SM', + 'GT-SO', + 'GT-SR', + 'GT-SU', + 'GT-TO', + 'GT-ZA', + 'GW-BA', + 'GW-BL', + 'GW-BM', + 'GW-BS', + 'GW-CA', + 'GW-GA', + 'GW-L', + 'GW-N', + 'GW-OI', + 'GW-QU', + 'GW-S', + 'GW-TO', + 'GY-BA', + 'GY-CU', + 'GY-DE', + 'GY-EB', + 'GY-ES', + 'GY-MA', + 'GY-PM', + 'GY-PT', + 'GY-UD', + 'GY-UT', + 'HN-AT', + 'HN-CH', + 'HN-CL', + 'HN-CM', + 'HN-CP', + 'HN-CR', + 'HN-EP', + 'HN-FM', + 'HN-GD', + 'HN-IB', + 'HN-IN', + 'HN-LE', + 'HN-LP', + 'HN-OC', + 'HN-OL', + 'HN-SB', + 'HN-VA', + 'HN-YO', + 'HR-01', + 'HR-02', + 'HR-03', + 'HR-04', + 'HR-05', + 'HR-06', + 'HR-07', + 'HR-08', + 'HR-09', + 'HR-10', + 'HR-11', + 'HR-12', + 'HR-13', + 'HR-14', + 'HR-15', + 'HR-16', + 'HR-17', + 'HR-18', + 'HR-19', + 'HR-20', + 'HR-21', + 'HT-AR', + 'HT-CE', + 'HT-GA', + 'HT-ND', + 'HT-NE', + 'HT-NO', + 'HT-OU', + 'HT-SD', + 'HT-SE', + 'HU-BA', + 'HU-BC', + 'HU-BE', + 'HU-BK', + 'HU-BU', + 'HU-BZ', + 'HU-CS', + 'HU-DE', + 'HU-DU', + 'HU-EG', + 'HU-FE', + 'HU-GS', + 'HU-GY', + 'HU-HB', + 'HU-HE', + 'HU-HV', + 'HU-JN', + 'HU-KE', + 'HU-KM', + 'HU-KV', + 'HU-MI', + 'HU-NK', + 'HU-NO', + 'HU-NY', + 'HU-PE', + 'HU-PS', + 'HU-SD', + 'HU-SF', + 'HU-SH', + 'HU-SK', + 'HU-SN', + 'HU-SO', + 'HU-SS', + 'HU-ST', + 'HU-SZ', + 'HU-TB', + 'HU-TO', + 'HU-VA', + 'HU-VE', + 'HU-VM', + 'HU-ZA', + 'HU-ZE', + 'ID-AC', + 'ID-BA', + 'ID-BB', + 'ID-BE', + 'ID-BT', + 'ID-GO', + 'ID-IJ', + 'ID-JA', + 'ID-JB', + 'ID-JI', + 'ID-JK', + 'ID-JT', + 'ID-JW', + 'ID-KA', + 'ID-KB', + 'ID-KI', + 'ID-KS', + 'ID-KT', + 'ID-LA', + 'ID-MA', + 'ID-MU', + 'ID-NB', + 'ID-NT', + 'ID-NU', + 'ID-PA', + 'ID-RI', + 'ID-SA', + 'ID-SB', + 'ID-SG', + 'ID-SL', + 'ID-SM', + 'ID-SN', + 'ID-SS', + 'ID-ST', + 'ID-SU', + 'ID-YO', + 'IE-C', + 'IE-C; 2', + 'IE-CE', + 'IE-CN', + 'IE-CW', + 'IE-D', + 'IE-DL', + 'IE-G', + 'IE-KE', + 'IE-KK', + 'IE-KY', + 'IE-L', + 'IE-LD', + 'IE-LH', + 'IE-LK', + 'IE-LM', + 'IE-LS', + 'IE-M', + 'IE-MH', + 'IE-MN', + 'IE-MO', + 'IE-OY', + 'IE-RN', + 'IE-SO', + 'IE-TA', + 'IE-U', + 'IE-WD', + 'IE-WH', + 'IE-WW', + 'IE-WX', + 'IL-D', + 'IL-HA', + 'IL-JM', + 'IL-M', + 'IL-TA', + 'IL-Z', + 'IN-AN', + 'IN-AP', + 'IN-AR', + 'IN-AS', + 'IN-BR', + 'IN-CH', + 'IN-CT', + 'IN-DD', + 'IN-DL', + 'IN-DN', + 'IN-GA', + 'IN-GJ', + 'IN-HP', + 'IN-HR', + 'IN-JH', + 'IN-JK', + 'IN-KA', + 'IN-KL', + 'IN-LD', + 'IN-MH', + 'IN-ML', + 'IN-MN', + 'IN-MP', + 'IN-MZ', + 'IN-NL', + 'IN-OR', + 'IN-PB', + 'IN-PY', + 'IN-RJ', + 'IN-SK', + 'IN-TN', + 'IN-TR', + 'IN-UL', + 'IN-UP', + 'IN-WB', + 'IQ-AN', + 'IQ-AR', + 'IQ-BA', + 'IQ-BB', + 'IQ-BG', + 'IQ-DA', + 'IQ-DI', + 'IQ-DQ', + 'IQ-KA', + 'IQ-MA', + 'IQ-MU', + 'IQ-NA', + 'IQ-NI', + 'IQ-QA', + 'IQ-SD', + 'IQ-SU', + 'IQ-TS', + 'IQ-WA', + 'IR-01', + 'IR-02', + 'IR-03', + 'IR-04', + 'IR-05', + 'IR-06', + 'IR-07', + 'IR-08', + 'IR-09', + 'IR-10', + 'IR-11', + 'IR-12', + 'IR-13', + 'IR-14', + 'IR-15', + 'IR-16', + 'IR-17', + 'IR-18', + 'IR-19', + 'IR-20', + 'IR-21', + 'IR-22', + 'IR-23', + 'IR-24', + 'IR-25', + 'IR-26', + 'IR-27', + 'IR-28', + 'IS-0', + 'IS-1', + 'IS-2', + 'IS-3', + 'IS-4', + 'IS-5', + 'IS-6', + 'IS-7', + 'IS-8', + 'IT-21', + 'IT-23', + 'IT-25', + 'IT-32', + 'IT-34', + 'IT-36', + 'IT-42', + 'IT-45', + 'IT-52', + 'IT-55', + 'IT-57', + 'IT-62', + 'IT-65', + 'IT-67', + 'IT-72', + 'IT-75', + 'IT-77', + 'IT-78', + 'IT-82', + 'IT-88', + 'IT-AG', + 'IT-AL', + 'IT-AN', + 'IT-AO', + 'IT-AP', + 'IT-AQ', + 'IT-AR', + 'IT-AT', + 'IT-AV', + 'IT-BA', + 'IT-BG', + 'IT-BI', + 'IT-BL', + 'IT-BN', + 'IT-BO', + 'IT-BR', + 'IT-BS', + 'IT-BZ', + 'IT-CA', + 'IT-CB', + 'IT-CE', + 'IT-CH', + 'IT-CL', + 'IT-CN', + 'IT-CO', + 'IT-CR', + 'IT-CS', + 'IT-CT', + 'IT-CZ', + 'IT-DU', + 'IT-EN', + 'IT-FE', + 'IT-FG', + 'IT-FI', + 'IT-FO', + 'IT-FR', + 'IT-GE', + 'IT-GO', + 'IT-GR', + 'IT-IM', + 'IT-IS', + 'IT-KR', + 'IT-LC', + 'IT-LE', + 'IT-LI', + 'IT-LO', + 'IT-LT', + 'IT-LU', + 'IT-MC', + 'IT-ME', + 'IT-MI', + 'IT-MN', + 'IT-MO', + 'IT-MS', + 'IT-MT', + 'IT-NA', + 'IT-NO', + 'IT-NU', + 'IT-OR', + 'IT-PA', + 'IT-PC', + 'IT-PD', + 'IT-PE', + 'IT-PG', + 'IT-PI', + 'IT-PN', + 'IT-PO', + 'IT-PR', + 'IT-PS', + 'IT-PT', + 'IT-PV', + 'IT-PZ', + 'IT-RA', + 'IT-RC', + 'IT-RE', + 'IT-RG', + 'IT-RI', + 'IT-RM', + 'IT-RN', + 'IT-RO', + 'IT-SA', + 'IT-SI', + 'IT-SO', + 'IT-SP', + 'IT-SR', + 'IT-SS', + 'IT-SV', + 'IT-TA', + 'IT-TE', + 'IT-TN', + 'IT-TO', + 'IT-TP', + 'IT-TR', + 'IT-TS', + 'IT-TV', + 'IT-VA', + 'IT-VB', + 'IT-VC', + 'IT-VE', + 'IT-VI', + 'IT-VR', + 'IT-VT', + 'IT-VV', + 'JM-01', + 'JM-02', + 'JM-03', + 'JM-04', + 'JM-05', + 'JM-06', + 'JM-07', + 'JM-08', + 'JM-09', + 'JM-10', + 'JM-11', + 'JM-12', + 'JM-13', + 'JM-14', + 'JO-AJ', + 'JO-AM', + 'JO-AQ', + 'JO-AT', + 'JO-AZ', + 'JO-BA', + 'JO-IR', + 'JO-JA', + 'JO-KA', + 'JO-MA', + 'JO-MD', + 'JO-MN', + 'JP-01', + 'JP-02', + 'JP-03', + 'JP-04', + 'JP-05', + 'JP-06', + 'JP-07', + 'JP-08', + 'JP-09', + 'JP-10', + 'JP-11', + 'JP-12', + 'JP-13', + 'JP-14', + 'JP-15', + 'JP-16', + 'JP-17', + 'JP-18', + 'JP-19', + 'JP-20', + 'JP-21', + 'JP-22', + 'JP-23', + 'JP-24', + 'JP-25', + 'JP-26', + 'JP-27', + 'JP-28', + 'JP-29', + 'JP-30', + 'JP-31', + 'JP-32', + 'JP-33', + 'JP-34', + 'JP-35', + 'JP-36', + 'JP-37', + 'JP-38', + 'JP-39', + 'JP-40', + 'JP-41', + 'JP-42', + 'JP-43', + 'JP-44', + 'JP-45', + 'JP-46', + 'JP-47', + 'KE-110', + 'KE-200', + 'KE-300', + 'KE-400', + 'KE-500', + 'KE-600', + 'KE-700', + 'KE-900', + 'KG-B', + 'KG-C', + 'KG-GB', + 'KG-J', + 'KG-N', + 'KG-O', + 'KG-T', + 'KG-Y', + 'KH-1', + 'KH-10', + 'KH-11', + 'KH-12', + 'KH-13', + 'KH-14', + 'KH-15', + 'KH-16', + 'KH-17', + 'KH-18', + 'KH-19', + 'KH-2', + 'KH-20', + 'KH-21', + 'KH-22', + 'KH-23', + 'KH-24', + 'KH-3', + 'KH-4', + 'KH-5', + 'KH-6', + 'KH-7', + 'KH-8', + 'KH-9', + 'KI-G', + 'KI-L', + 'KI-P', + 'KM-A', + 'KM-G', + 'KM-M', + 'KP-CHA', + 'KP-HAB', + 'KP-HAN', + 'KP-HWB', + 'KP-HWN', + 'KP-KAE', + 'KP-KAN', + 'KP-NAJ', + 'KP-NAM', + 'KP-PYB', + 'KP-PYN', + 'KP-PYO', + 'KP-YAN', + 'KR-11', + 'KR-26', + 'KR-27', + 'KR-28', + 'KR-29', + 'KR-30', + 'KR-31', + 'KR-41', + 'KR-42', + 'KR-43', + 'KR-44', + 'KR-45', + 'KR-46', + 'KR-47', + 'KR-48', + 'KR-49', + 'KW-AH', + 'KW-FA', + 'KW-HA', + 'KW-JA', + 'KW-KU', + 'KZ-AKM', + 'KZ-AKT', + 'KZ-ALA', + 'KZ-ALM', + 'KZ-AST', + 'KZ-ATY', + 'KZ-KAR', + 'KZ-KUS', + 'KZ-KZY', + 'KZ-MAN', + 'KZ-PAV', + 'KZ-SEV', + 'KZ-VOS', + 'KZ-YUZ', + 'KZ-ZAP', + 'KZ-ZHA', + 'LA-AT', + 'LA-BK', + 'LA-BL', + 'LA-CH', + 'LA-HO', + 'LA-KH', + 'LA-LM', + 'LA-LP', + 'LA-OU', + 'LA-PH', + 'LA-SL', + 'LA-SV', + 'LA-VI', + 'LA-VT', + 'LA-XA', + 'LA-XE', + 'LA-XI', + 'LA-XN', + 'LB-AS', + 'LB-BA', + 'LB-BI', + 'LB-JA', + 'LB-JL', + 'LB-NA', + 'LK-1', + 'LK-11', + 'LK-12', + 'LK-13', + 'LK-2', + 'LK-21', + 'LK-22', + 'LK-23', + 'LK-3', + 'LK-31', + 'LK-32', + 'LK-33', + 'LK-4', + 'LK-41', + 'LK-42', + 'LK-43', + 'LK-44', + 'LK-45', + 'LK-5', + 'LK-51', + 'LK-52', + 'LK-53', + 'LK-6', + 'LK-61', + 'LK-62', + 'LK-7', + 'LK-71', + 'LK-72', + 'LK-8', + 'LK-81', + 'LK-82', + 'LK-9', + 'LK-91', + 'LK-92', + 'LR-BG', + 'LR-BM', + 'LR-CM', + 'LR-GB', + 'LR-GG', + 'LR-GK', + 'LR-LO', + 'LR-MG', + 'LR-MO', + 'LR-MY', + 'LR-NI', + 'LR-RI', + 'LR-SI', + 'LS-A', + 'LS-B', + 'LS-C', + 'LS-D', + 'LS-E', + 'LS-F', + 'LS-G', + 'LS-H', + 'LS-J', + 'LS-K', + 'LT-AL', + 'LT-KL', + 'LT-KU', + 'LT-MR', + 'LT-PN', + 'LT-SA', + 'LT-TA', + 'LT-TE', + 'LT-UT', + 'LT-VL', + 'LU-D', + 'LU-G', + 'LU-L', + 'LV-AI', + 'LV-AL', + 'LV-BL', + 'LV-BU', + 'LV-CE', + 'LV-DA', + 'LV-DGV', + 'LV-DO', + 'LV-GU', + 'LV-JEL', + 'LV-JK', + 'LV-JL', + 'LV-JUR', + 'LV-KR', + 'LV-KU', + 'LV-LE', + 'LV-LM', + 'LV-LPX', + 'LV-LU', + 'LV-MA', + 'LV-OG', + 'LV-PR', + 'LV-RE', + 'LV-REZ', + 'LV-RI', + 'LV-RIX', + 'LV-SA', + 'LV-TA', + 'LV-TU', + 'LV-VE', + 'LV-VEN', + 'LV-VK', + 'LV-VM', + 'LY-BA', + 'LY-BU', + 'LY-FA', + 'LY-JA', + 'LY-JG', + 'LY-JU', + 'LY-MI', + 'LY-NA', + 'LY-SF', + 'LY-TB', + 'LY-WA', + 'LY-WU', + 'LY-ZA', + 'MA-01', + 'MA-02', + 'MA-03', + 'MA-04', + 'MA-05', + 'MA-06', + 'MA-07', + 'MA-08', + 'MA-09', + 'MA-10', + 'MA-11', + 'MA-12', + 'MA-13', + 'MA-14', + 'MA-15', + 'MA-16', + 'MA-AGD', + 'MA-ASZ', + 'MA-AZI', + 'MA-BAH', + 'MA-BEM', + 'MA-BER', + 'MA-BES', + 'MA-BOD', + 'MA-BOM', + 'MA-CAS', + 'MA-CHE', + 'MA-CHI', + 'MA-ERR', + 'MA-ESI', + 'MA-ESM', + 'MA-FES', + 'MA-FIG', + 'MA-GUE', + 'MA-HAJ', + 'MA-HAO', + 'MA-HOC', + 'MA-IFR', + 'MA-JDI', + 'MA-JRA', + 'MA-KEN', + 'MA-KES', + 'MA-KHE', + 'MA-KHN', + 'MA-KHO', + 'MA-LAA', + 'MA-LAR', + 'MA-MAR', + 'MA-MEK', + 'MA-MEL', + 'MA-NAD', + 'MA-OUA', + 'MA-OUD', + 'MA-OUJ', + 'MA-RBA', + 'MA-SAF', + 'MA-SEF', + 'MA-SET', + 'MA-SIK', + 'MA-TAO', + 'MA-TAR', + 'MA-TAT', + 'MA-TAZ', + 'MA-TET', + 'MA-TIZ', + 'MA-TNG', + 'MA-TNT', + 'MD-BA', + 'MD-CA', + 'MD-CH', + 'MD-CU', + 'MD-ED', + 'MD-GA', + 'MD-LA', + 'MD-OR', + 'MD-SN', + 'MD-SO', + 'MD-TA', + 'MD-TI', + 'MD-UN', + 'MG-A', + 'MG-D', + 'MG-F', + 'MG-M', + 'MG-T', + 'MG-U', + 'MH-ALK', + 'MH-ALL', + 'MH-ARN', + 'MH-AUR', + 'MH-EBO', + 'MH-ENI', + 'MH-JAL', + 'MH-KIL', + 'MH-KWA', + 'MH-L', + 'MH-LAE', + 'MH-LIB', + 'MH-LIK', + 'MH-MAJ', + 'MH-MAL', + 'MH-MEJ', + 'MH-MIL', + 'MH-NMK', + 'MH-NMU', + 'MH-RON', + 'MH-T', + 'MH-UJA', + 'MH-UJL', + 'MH-UTI', + 'MH-WTH', + 'MH-WTJ', + 'ML-1', + 'ML-2', + 'ML-3', + 'ML-4', + 'ML-5', + 'ML-6', + 'ML-7', + 'ML-8', + 'ML-BKO', + 'MM-01', + 'MM-02', + 'MM-03', + 'MM-04', + 'MM-05', + 'MM-06', + 'MM-07', + 'MM-11', + 'MM-12', + 'MM-13', + 'MM-14', + 'MM-15', + 'MM-16', + 'MM-17', + 'MN-035', + 'MN-037', + 'MN-039', + 'MN-041', + 'MN-043', + 'MN-046', + 'MN-047', + 'MN-049', + 'MN-051', + 'MN-053', + 'MN-055', + 'MN-057', + 'MN-059', + 'MN-061', + 'MN-063', + 'MN-064', + 'MN-065', + 'MN-067', + 'MN-069', + 'MN-071', + 'MN-073', + 'MN-1', + 'MR-01', + 'MR-02', + 'MR-03', + 'MR-04', + 'MR-05', + 'MR-06', + 'MR-07', + 'MR-08', + 'MR-09', + 'MR-10', + 'MR-11', + 'MR-12', + 'MR-NKC', + 'MU-AG', + 'MU-BL', + 'MU-BR', + 'MU-CC', + 'MU-CU', + 'MU-FL', + 'MU-GP', + 'MU-MO', + 'MU-PA', + 'MU-PL', + 'MU-PU', + 'MU-PW', + 'MU-QB', + 'MU-RO', + 'MU-RR', + 'MU-SA', + 'MU-VP', + 'MV-01', + 'MV-02', + 'MV-03', + 'MV-04', + 'MV-05', + 'MV-07', + 'MV-08', + 'MV-12', + 'MV-13', + 'MV-14', + 'MV-17', + 'MV-20', + 'MV-23', + 'MV-24', + 'MV-25', + 'MV-26', + 'MV-27', + 'MV-28', + 'MV-29', + 'MV-MLE', + 'MW-BA', + 'MW-BL', + 'MW-C', + 'MW-CK', + 'MW-CR', + 'MW-CT', + 'MW-DE', + 'MW-DO', + 'MW-KR', + 'MW-KS', + 'MW-LI', + 'MW-LK', + 'MW-MC', + 'MW-MG', + 'MW-MH', + 'MW-MU', + 'MW-MW', + 'MW-MZ', + 'MW-N', + 'MW-NB', + 'MW-NI', + 'MW-NK', + 'MW-NS', + 'MW-NU', + 'MW-PH', + 'MW-RU', + 'MW-S', + 'MW-SA', + 'MW-TH', + 'MW-ZO', + 'MX-AGU', + 'MX-BCN', + 'MX-BCS', + 'MX-CAM', + 'MX-CHH', + 'MX-CHP', + 'MX-COA', + 'MX-COL', + 'MX-DIF', + 'MX-DUR', + 'MX-GRO', + 'MX-GUA', + 'MX-HID', + 'MX-JAL', + 'MX-MEX', + 'MX-MIC', + 'MX-MOR', + 'MX-NAY', + 'MX-NLE', + 'MX-OAX', + 'MX-PUE', + 'MX-QUE', + 'MX-ROO', + 'MX-SIN', + 'MX-SLP', + 'MX-SON', + 'MX-TAB', + 'MX-TAM', + 'MX-TLA', + 'MX-VER', + 'MX-YUC', + 'MX-ZAC', + 'MY-A', + 'MY-B', + 'MY-C', + 'MY-D', + 'MY-J', + 'MY-K', + 'MY-L', + 'MY-M', + 'MY-N', + 'MY-P', + 'MY-R', + 'MY-SA', + 'MY-SK', + 'MY-T', + 'MY-W', + 'MZ-A', + 'MZ-B', + 'MZ-G', + 'MZ-I', + 'MZ-L', + 'MZ-MPM', + 'MZ-N', + 'MZ-P', + 'MZ-Q', + 'MZ-S', + 'MZ-T', + 'NA-CA', + 'NA-ER', + 'NA-HA', + 'NA-KA', + 'NA-KH', + 'NA-KU', + 'NA-OD', + 'NA-OH', + 'NA-OK', + 'NA-ON', + 'NA-OS', + 'NA-OT', + 'NA-OW', + 'NE-1', + 'NE-2', + 'NE-3', + 'NE-4', + 'NE-5', + 'NE-6', + 'NE-7', + 'NE-8', + 'NG-AB', + 'NG-AD', + 'NG-AK', + 'NG-AN', + 'NG-BA', + 'NG-BE', + 'NG-BO', + 'NG-BY', + 'NG-CR', + 'NG-DE', + 'NG-EB', + 'NG-ED', + 'NG-EK', + 'NG-EN', + 'NG-FC', + 'NG-GO', + 'NG-IM', + 'NG-JI', + 'NG-KD', + 'NG-KE', + 'NG-KN', + 'NG-KO', + 'NG-KT', + 'NG-KW', + 'NG-LA', + 'NG-NA', + 'NG-NI', + 'NG-OG', + 'NG-ON', + 'NG-OS', + 'NG-OY', + 'NG-PL', + 'NG-RI', + 'NG-SO', + 'NG-TA', + 'NG-YO', + 'NG-ZA', + 'NI-AN', + 'NI-AS', + 'NI-BO', + 'NI-CA', + 'NI-CI', + 'NI-CO', + 'NI-ES', + 'NI-GR', + 'NI-JI', + 'NI-LE', + 'NI-MD', + 'NI-MN', + 'NI-MS', + 'NI-MT', + 'NI-NS', + 'NI-RI', + 'NI-SJ', + 'NL-DR', + 'NL-FL', + 'NL-FR', + 'NL-GE', + 'NL-GR', + 'NL-LI', + 'NL-NB', + 'NL-NH', + 'NL-OV', + 'NL-UT', + 'NL-ZE', + 'NL-ZH', + 'NO-01', + 'NO-02', + 'NO-03', + 'NO-04', + 'NO-05', + 'NO-06', + 'NO-07', + 'NO-08', + 'NO-09', + 'NO-10', + 'NO-11', + 'NO-12', + 'NO-14', + 'NO-15', + 'NO-16', + 'NO-17', + 'NO-18', + 'NO-19', + 'NO-20', + 'NO-21', + 'NO-22', + 'NP-1', + 'NP-2', + 'NP-3', + 'NP-4', + 'NP-5', + 'NP-BA', + 'NP-BH', + 'NP-DH', + 'NP-GA', + 'NP-JA', + 'NP-KA', + 'NP-KO', + 'NP-LU', + 'NP-MA', + 'NP-ME', + 'NP-NA', + 'NP-RA', + 'NP-SA', + 'NP-SE', + 'NZ-AUK', + 'NZ-BOP', + 'NZ-CAN', + 'NZ-GIS', + 'NZ-HKB', + 'NZ-MBH', + 'NZ-MWT', + 'NZ-N', + 'NZ-NSN', + 'NZ-NTL', + 'NZ-OTA', + 'NZ-S', + 'NZ-STL', + 'NZ-TAS', + 'NZ-TKI', + 'NZ-WGN', + 'NZ-WKO', + 'NZ-WTC', + 'OM-BA', + 'OM-DA', + 'OM-JA', + 'OM-MA', + 'OM-MU', + 'OM-SH', + 'OM-WU', + 'OM-ZA', + 'PA-0', + 'PA-1', + 'PA-2', + 'PA-3', + 'PA-4', + 'PA-5', + 'PA-6', + 'PA-7', + 'PA-8', + 'PA-9', + 'PE-AMA', + 'PE-ANC', + 'PE-APU', + 'PE-ARE', + 'PE-AYA', + 'PE-CAJ', + 'PE-CAL', + 'PE-CUS', + 'PE-HUC', + 'PE-HUV', + 'PE-ICA', + 'PE-JUN', + 'PE-LAL', + 'PE-LAM', + 'PE-LIM', + 'PE-LOR', + 'PE-MDD', + 'PE-MOQ', + 'PE-PAS', + 'PE-PIU', + 'PE-PUN', + 'PE-SAM', + 'PE-TAC', + 'PE-TUM', + 'PE-UCA', + 'PG-CPK', + 'PG-CPM', + 'PG-EBR', + 'PG-EHG', + 'PG-EPW', + 'PG-ESW', + 'PG-GPK', + 'PG-MBA', + 'PG-MPL', + 'PG-MPM', + 'PG-MRL', + 'PG-NCD', + 'PG-NIK', + 'PG-NPP', + 'PG-NSA', + 'PG-SAN', + 'PG-SHM', + 'PG-WBK', + 'PG-WHM', + 'PG-WPD', + 'PH-00', + 'PH-01', + 'PH-02', + 'PH-03', + 'PH-04', + 'PH-05', + 'PH-06', + 'PH-07', + 'PH-08', + 'PH-09', + 'PH-10', + 'PH-11', + 'PH-12', + 'PH-13', + 'PH-14', + 'PH-15', + 'PH-ABR', + 'PH-AGN', + 'PH-AGS', + 'PH-AKL', + 'PH-ALB', + 'PH-ANT', + 'PH-APA', + 'PH-AUR', + 'PH-BAN', + 'PH-BAS', + 'PH-BEN', + 'PH-BIL', + 'PH-BOH', + 'PH-BTG', + 'PH-BTN', + 'PH-BUK', + 'PH-BUL', + 'PH-CAG', + 'PH-CAM', + 'PH-CAN', + 'PH-CAP', + 'PH-CAS', + 'PH-CAT', + 'PH-CAV', + 'PH-CEB', + 'PH-COM', + 'PH-DAO', + 'PH-DAS', + 'PH-DAV', + 'PH-EAS', + 'PH-GUI', + 'PH-IFU', + 'PH-ILI', + 'PH-ILN', + 'PH-ILS', + 'PH-ISA', + 'PH-KAL', + 'PH-LAG', + 'PH-LAN', + 'PH-LAS', + 'PH-LEY', + 'PH-LUN', + 'PH-MAD', + 'PH-MAG', + 'PH-MAS', + 'PH-MDC', + 'PH-MDR', + 'PH-MOU', + 'PH-MSC', + 'PH-MSR', + 'PH-NCO', + 'PH-NEC', + 'PH-NER', + 'PH-NSA', + 'PH-NUE', + 'PH-NUV', + 'PH-PAM', + 'PH-PAN', + 'PH-PLW', + 'PH-QUE', + 'PH-QUI', + 'PH-RIZ', + 'PH-ROM', + 'PH-SAR', + 'PH-SCO', + 'PH-SIG', + 'PH-SLE', + 'PH-SLU', + 'PH-SOR', + 'PH-SUK', + 'PH-SUN', + 'PH-SUR', + 'PH-TAR', + 'PH-TAW', + 'PH-WSA', + 'PH-ZAN', + 'PH-ZAS', + 'PH-ZMB', + 'PH-ZSI', + 'PK-BA', + 'PK-IS', + 'PK-JK', + 'PK-NA', + 'PK-NW', + 'PK-PB', + 'PK-SD', + 'PK-TA', + 'PL-DS', + 'PL-KP', + 'PL-LB', + 'PL-LD', + 'PL-LU', + 'PL-MA', + 'PL-MZ', + 'PL-OP', + 'PL-PD', + 'PL-PK', + 'PL-PM', + 'PL-SK', + 'PL-SL', + 'PL-WN', + 'PL-WP', + 'PL-ZP', + 'PT-01', + 'PT-02', + 'PT-03', + 'PT-04', + 'PT-05', + 'PT-06', + 'PT-07', + 'PT-08', + 'PT-09', + 'PT-10', + 'PT-11', + 'PT-12', + 'PT-13', + 'PT-14', + 'PT-15', + 'PT-16', + 'PT-17', + 'PT-18', + 'PT-20', + 'PT-30', + 'PY-1', + 'PY-10', + 'PY-11', + 'PY-12', + 'PY-13', + 'PY-14', + 'PY-15', + 'PY-16', + 'PY-19', + 'PY-2', + 'PY-3', + 'PY-4', + 'PY-5', + 'PY-6', + 'PY-7', + 'PY-8', + 'PY-9', + 'PY-ASU', + 'QA-DA', + 'QA-GH', + 'QA-JB', + 'QA-JU', + 'QA-KH', + 'QA-MS', + 'QA-RA', + 'QA-US', + 'QA-WA', + 'RO-AB', + 'RO-AG', + 'RO-AR', + 'RO-B', + 'RO-BC', + 'RO-BH', + 'RO-BN', + 'RO-BR', + 'RO-BT', + 'RO-BV', + 'RO-BZ', + 'RO-CJ', + 'RO-CL', + 'RO-CS', + 'RO-CT', + 'RO-CV', + 'RO-DB', + 'RO-DJ', + 'RO-GJ', + 'RO-GL', + 'RO-GR', + 'RO-HD', + 'RO-HR', + 'RO-IF', + 'RO-IL', + 'RO-IS', + 'RO-MH', + 'RO-MM', + 'RO-MS', + 'RO-NT', + 'RO-OT', + 'RO-PH', + 'RO-SB', + 'RO-SJ', + 'RO-SM', + 'RO-SV', + 'RO-TL', + 'RO-TM', + 'RO-TR', + 'RO-VL', + 'RO-VN', + 'RO-VS', + 'RU-AD', + 'RU-AGB', + 'RU-AL', + 'RU-ALT', + 'RU-AMU', + 'RU-ARK', + 'RU-AST', + 'RU-BA', + 'RU-BEL', + 'RU-BRY', + 'RU-BU', + 'RU-CE', + 'RU-CHE', + 'RU-CHI', + 'RU-CHU', + 'RU-CU', + 'RU-DA', + 'RU-DU', + 'RU-EVE', + 'RU-IN', + 'RU-IRK', + 'RU-IVA', + 'RU-KAM', + 'RU-KB', + 'RU-KC', + 'RU-KDA', + 'RU-KEM', + 'RU-KGD', + 'RU-KGN', + 'RU-KHA', + 'RU-KHM', + 'RU-KIR', + 'RU-KK', + 'RU-KL', + 'RU-KLU', + 'RU-KO', + 'RU-KOP', + 'RU-KOR', + 'RU-KOS', + 'RU-KR', + 'RU-KRS', + 'RU-KYA', + 'RU-LEN', + 'RU-LIP', + 'RU-MAG', + 'RU-ME', + 'RU-MO', + 'RU-MOS', + 'RU-MOW', + 'RU-MUR', + 'RU-NEN', + 'RU-NGR', + 'RU-NIZ', + 'RU-NVS', + 'RU-OMS', + 'RU-ORE', + 'RU-ORL', + 'RU-PER', + 'RU-PNZ', + 'RU-PRI', + 'RU-PSK', + 'RU-ROS', + 'RU-RYA', + 'RU-SA', + 'RU-SAK', + 'RU-SAM', + 'RU-SAR', + 'RU-SE', + 'RU-SMO', + 'RU-SPE', + 'RU-STA', + 'RU-SVE', + 'RU-TA', + 'RU-TAM', + 'RU-TAY', + 'RU-TOM', + 'RU-TUL', + 'RU-TVE', + 'RU-TY', + 'RU-TYU', + 'RU-ULY', + 'RU-UOB', + 'RU-VGG', + 'RU-VLA', + 'RU-VLG', + 'RU-VOR', + 'RU-YAN', + 'RU-YAR', + 'RU-YEV', + 'RW-B', + 'RW-C', + 'RW-D', + 'RW-E', + 'RW-F', + 'RW-G', + 'RW-H', + 'RW-I', + 'RW-J', + 'RW-K', + 'RW-L', + 'RW-M', + 'SA-01', + 'SA-02', + 'SA-03', + 'SA-04', + 'SA-05', + 'SA-06', + 'SA-07', + 'SA-08', + 'SA-09', + 'SA-10', + 'SA-11', + 'SA-12', + 'SA-14', + 'SB-CE', + 'SB-CT', + 'SB-GU', + 'SB-IS', + 'SB-MK', + 'SB-ML', + 'SB-TE', + 'SB-WE', + 'SD-01', + 'SD-02', + 'SD-03', + 'SD-04', + 'SD-05', + 'SD-06', + 'SD-07', + 'SD-08', + 'SD-09', + 'SD-10', + 'SD-11', + 'SD-12', + 'SD-13', + 'SD-14', + 'SD-15', + 'SD-16', + 'SD-17', + 'SD-18', + 'SD-19', + 'SD-20', + 'SD-21', + 'SD-22', + 'SD-23', + 'SD-24', + 'SD-25', + 'SD-26', + 'SE-AB', + 'SE-AC', + 'SE-BD', + 'SE-C', + 'SE-D', + 'SE-E', + 'SE-F', + 'SE-G', + 'SE-H', + 'SE-I', + 'SE-K', + 'SE-M', + 'SE-N', + 'SE-O', + 'SE-S', + 'SE-T', + 'SE-U', + 'SE-W', + 'SE-X', + 'SE-Y', + 'SE-Z', + 'SH-AC', + 'SH-SH', + 'SH-TA', + 'SI-01', + 'SI-02', + 'SI-03', + 'SI-04', + 'SI-05', + 'SI-06', + 'SI-07', + 'SI-08', + 'SI-09', + 'SI-10', + 'SI-11', + 'SI-12', + 'SK-BC', + 'SK-BL', + 'SK-KI', + 'SK-NI', + 'SK-PV', + 'SK-TA', + 'SK-TC', + 'SK-ZI', + 'SL-E', + 'SL-N', + 'SL-S', + 'SL-W', + 'SN-DB', + 'SN-DK', + 'SN-FK', + 'SN-KD', + 'SN-KL', + 'SN-LG', + 'SN-SL', + 'SN-TC', + 'SN-TH', + 'SN-ZG', + 'SO-AW', + 'SO-BK', + 'SO-BN', + 'SO-BR', + 'SO-BY', + 'SO-GA', + 'SO-GE', + 'SO-HI', + 'SO-JD', + 'SO-JH', + 'SO-MU', + 'SO-NU', + 'SO-SA', + 'SO-SD', + 'SO-SH', + 'SO-SO', + 'SO-TO', + 'SO-WO', + 'SR-BR', + 'SR-CM', + 'SR-CR', + 'SR-MA', + 'SR-NI', + 'SR-PM', + 'SR-PR', + 'SR-SA', + 'SR-SI', + 'SR-WA', + 'ST-P', + 'ST-S', + 'SV-AH', + 'SV-CA', + 'SV-CH', + 'SV-CU', + 'SV-LI', + 'SV-MO', + 'SV-PA', + 'SV-SA', + 'SV-SM', + 'SV-SO', + 'SV-SS', + 'SV-SV', + 'SV-UN', + 'SV-US', + 'SY-DI', + 'SY-DR', + 'SY-DY', + 'SY-HA', + 'SY-HI', + 'SY-HL', + 'SY-HM', + 'SY-ID', + 'SY-LA', + 'SY-QU', + 'SY-RA', + 'SY-RD', + 'SY-SU', + 'SY-TA', + 'SZ-HH', + 'SZ-LU', + 'SZ-MA', + 'SZ-SH', + 'TD-BA', + 'TD-BET', + 'TD-BI', + 'TD-CB', + 'TD-GR', + 'TD-KA', + 'TD-LC', + 'TD-LO', + 'TD-LR', + 'TD-MC', + 'TD-MK', + 'TD-OD', + 'TD-SA', + 'TD-TA', + 'TG-C', + 'TG-K', + 'TG-M', + 'TG-P', + 'TG-S', + 'TH-10', + 'TH-11', + 'TH-12', + 'TH-13', + 'TH-14', + 'TH-15', + 'TH-16', + 'TH-17', + 'TH-18', + 'TH-19', + 'TH-20', + 'TH-21', + 'TH-22', + 'TH-23', + 'TH-24', + 'TH-25', + 'TH-26', + 'TH-27', + 'TH-30', + 'TH-31', + 'TH-32', + 'TH-33', + 'TH-34', + 'TH-35', + 'TH-36', + 'TH-37', + 'TH-39', + 'TH-40', + 'TH-41', + 'TH-42', + 'TH-43', + 'TH-44', + 'TH-45', + 'TH-46', + 'TH-47', + 'TH-48', + 'TH-49', + 'TH-50', + 'TH-51', + 'TH-52', + 'TH-53', + 'TH-54', + 'TH-55', + 'TH-56', + 'TH-57', + 'TH-58', + 'TH-60', + 'TH-61', + 'TH-62', + 'TH-63', + 'TH-64', + 'TH-65', + 'TH-66', + 'TH-67', + 'TH-70', + 'TH-71', + 'TH-72', + 'TH-73', + 'TH-74', + 'TH-75', + 'TH-76', + 'TH-77', + 'TH-80', + 'TH-81', + 'TH-82', + 'TH-83', + 'TH-84', + 'TH-85', + 'TH-86', + 'TH-90', + 'TH-91', + 'TH-92', + 'TH-93', + 'TH-94', + 'TH-95', + 'TH-96', + 'TH-S', + 'TJ-GB', + 'TJ-KT', + 'TJ-SU', + 'TL-AL', + 'TL-AN', + 'TL-BA', + 'TL-BO', + 'TL-CO', + 'TL-DI', + 'TL-ER', + 'TL-LA', + 'TL-LI', + 'TL-MF', + 'TL-MT', + 'TL-OE', + 'TL-VI', + 'TM-A', + 'TM-B', + 'TM-D', + 'TM-L', + 'TM-M', + 'TN-11', + 'TN-12', + 'TN-13', + 'TN-21', + 'TN-22', + 'TN-23', + 'TN-31', + 'TN-32', + 'TN-33', + 'TN-34', + 'TN-41', + 'TN-42', + 'TN-43', + 'TN-51', + 'TN-52', + 'TN-53', + 'TN-61', + 'TN-71', + 'TN-72', + 'TN-73', + 'TN-81', + 'TN-82', + 'TN-83', + 'TR-01', + 'TR-02', + 'TR-03', + 'TR-04', + 'TR-05', + 'TR-06', + 'TR-07', + 'TR-08', + 'TR-09', + 'TR-10', + 'TR-11', + 'TR-12', + 'TR-13', + 'TR-14', + 'TR-15', + 'TR-16', + 'TR-17', + 'TR-18', + 'TR-19', + 'TR-20', + 'TR-21', + 'TR-22', + 'TR-23', + 'TR-24', + 'TR-25', + 'TR-26', + 'TR-27', + 'TR-28', + 'TR-29', + 'TR-30', + 'TR-31', + 'TR-32', + 'TR-33', + 'TR-34', + 'TR-35', + 'TR-36', + 'TR-37', + 'TR-38', + 'TR-39', + 'TR-40', + 'TR-41', + 'TR-42', + 'TR-43', + 'TR-44', + 'TR-45', + 'TR-46', + 'TR-47', + 'TR-48', + 'TR-49', + 'TR-50', + 'TR-51', + 'TR-52', + 'TR-53', + 'TR-54', + 'TR-55', + 'TR-56', + 'TR-57', + 'TR-58', + 'TR-59', + 'TR-60', + 'TR-61', + 'TR-62', + 'TR-63', + 'TR-64', + 'TR-65', + 'TR-66', + 'TR-67', + 'TR-68', + 'TR-69', + 'TR-70', + 'TR-71', + 'TR-72', + 'TR-73', + 'TR-74', + 'TR-75', + 'TR-76', + 'TR-77', + 'TR-78', + 'TR-79', + 'TR-80', + 'TR-81', + 'TT-ARI', + 'TT-CHA', + 'TT-CTT', + 'TT-DMN', + 'TT-ETO', + 'TT-PED', + 'TT-POS', + 'TT-PRT', + 'TT-PTF', + 'TT-RCM', + 'TT-SFO', + 'TT-SGE', + 'TT-SIP', + 'TT-SJL', + 'TT-TUP', + 'TT-WTO', + 'TW-CHA', + 'TW-CYQ', + 'TW-HSQ', + 'TW-HUA', + 'TW-ILA', + 'TW-KEE', + 'TW-KHQ', + 'TW-MIA', + 'TW-NAN', + 'TW-PEN', + 'TW-PIF', + 'TW-TAO', + 'TW-TNQ', + 'TW-TPQ', + 'TW-TTT', + 'TW-TXQ', + 'TW-YUN', + 'TZ-01', + 'TZ-02', + 'TZ-03', + 'TZ-04', + 'TZ-05', + 'TZ-06', + 'TZ-07', + 'TZ-08', + 'TZ-09', + 'TZ-10', + 'TZ-11', + 'TZ-12', + 'TZ-13', + 'TZ-14', + 'TZ-15', + 'TZ-16', + 'TZ-17', + 'TZ-18', + 'TZ-19', + 'TZ-20', + 'TZ-21', + 'TZ-22', + 'TZ-23', + 'TZ-24', + 'TZ-25', + 'UA-05', + 'UA-07', + 'UA-09', + 'UA-12', + 'UA-14', + 'UA-18', + 'UA-21', + 'UA-23', + 'UA-26', + 'UA-30', + 'UA-32', + 'UA-35', + 'UA-40', + 'UA-43', + 'UA-46', + 'UA-48', + 'UA-51', + 'UA-53', + 'UA-56', + 'UA-59', + 'UA-61', + 'UA-63', + 'UA-65', + 'UA-68', + 'UA-71', + 'UA-74', + 'UA-77', + 'UG-AJM', + 'UG-APA', + 'UG-ARU', + 'UG-BUA', + 'UG-BUG', + 'UG-BUN', + 'UG-BUS', + 'UG-C', + 'UG-E', + 'UG-GUL', + 'UG-HOI', + 'UG-IGA', + 'UG-JIN', + 'UG-KAP', + 'UG-KAS', + 'UG-KAT', + 'UG-KBL', + 'UG-KBR', + 'UG-KIB', + 'UG-KIS', + 'UG-KIT', + 'UG-KLA', + 'UG-KLE', + 'UG-KLG', + 'UG-KLI', + 'UG-KOT', + 'UG-KUM', + 'UG-LIR', + 'UG-LUW', + 'UG-MBL', + 'UG-MBR', + 'UG-MOR', + 'UG-MOY', + 'UG-MPI', + 'UG-MSI', + 'UG-MSK', + 'UG-MUB', + 'UG-MUK', + 'UG-N', + 'UG-NAK', + 'UG-NEB', + 'UG-NTU', + 'UG-PAL', + 'UG-RAK', + 'UG-RUK', + 'UG-SEM', + 'UG-SOR', + 'UG-TOR', + 'UG-W', + 'UM-67', + 'UM-71', + 'UM-76', + 'UM-79', + 'UM-81', + 'UM-84', + 'UM-86', + 'UM-89', + 'UM-95', + 'US-AK', + 'US-AL', + 'US-AR', + 'US-AS', + 'US-AZ', + 'US-CA', + 'US-CO', + 'US-CT', + 'US-DC', + 'US-DE', + 'US-FL', + 'US-GA', + 'US-GU', + 'US-HI', + 'US-IA', + 'US-ID', + 'US-IL', + 'US-IN', + 'US-KS', + 'US-KY', + 'US-LA', + 'US-MA', + 'US-MD', + 'US-ME', + 'US-MI', + 'US-MN', + 'US-MO', + 'US-MP', + 'US-MS', + 'US-MT', + 'US-NC', + 'US-ND', + 'US-NE', + 'US-NH', + 'US-NJ', + 'US-NM', + 'US-NV', + 'US-NY', + 'US-OH', + 'US-OK', + 'US-OR', + 'US-PA', + 'US-PR', + 'US-RI', + 'US-SC', + 'US-SD', + 'US-TN', + 'US-TX', + 'US-UM', + 'US-UT', + 'US-VA', + 'US-VI', + 'US-VT', + 'US-WA', + 'US-WI', + 'US-WV', + 'US-WY', + 'UY-AR', + 'UY-CA', + 'UY-CL', + 'UY-CO', + 'UY-DU', + 'UY-FD', + 'UY-FS', + 'UY-LA', + 'UY-MA', + 'UY-MO', + 'UY-PA', + 'UY-RN', + 'UY-RO', + 'UY-RV', + 'UY-SA', + 'UY-SJ', + 'UY-SO', + 'UY-TA', + 'UY-TT', + 'UZ-AN', + 'UZ-BU', + 'UZ-FA', + 'UZ-JI', + 'UZ-NG', + 'UZ-NW', + 'UZ-QA', + 'UZ-QR', + 'UZ-SA', + 'UZ-SI', + 'UZ-SU', + 'UZ-TK', + 'UZ-TO', + 'UZ-XO', + 'VE-A', + 'VE-B', + 'VE-C', + 'VE-D', + 'VE-E', + 'VE-F', + 'VE-G', + 'VE-H', + 'VE-I', + 'VE-J', + 'VE-K', + 'VE-L', + 'VE-M', + 'VE-N', + 'VE-O', + 'VE-P', + 'VE-R', + 'VE-S', + 'VE-T', + 'VE-U', + 'VE-V', + 'VE-W', + 'VE-X', + 'VE-Y', + 'VE-Z', + 'VN-01', + 'VN-02', + 'VN-03', + 'VN-04', + 'VN-05', + 'VN-06', + 'VN-07', + 'VN-09', + 'VN-13', + 'VN-14', + 'VN-15', + 'VN-18', + 'VN-20', + 'VN-21', + 'VN-22', + 'VN-23', + 'VN-24', + 'VN-25', + 'VN-26', + 'VN-27', + 'VN-28', + 'VN-29', + 'VN-30', + 'VN-31', + 'VN-32', + 'VN-33', + 'VN-34', + 'VN-35', + 'VN-36', + 'VN-37', + 'VN-39', + 'VN-40', + 'VN-41', + 'VN-43', + 'VN-44', + 'VN-45', + 'VN-46', + 'VN-47', + 'VN-48', + 'VN-49', + 'VN-50', + 'VN-51', + 'VN-52', + 'VN-53', + 'VN-54', + 'VN-55', + 'VN-56', + 'VN-57', + 'VN-58', + 'VN-59', + 'VN-60', + 'VN-61', + 'VN-62', + 'VN-63', + 'VN-64', + 'VN-65', + 'VN-66', + 'VN-67', + 'VN-68', + 'VN-69', + 'VN-70', + 'VU-MAP', + 'VU-PAM', + 'VU-SAM', + 'VU-SEE', + 'VU-TAE', + 'VU-TOB', + 'WS-AA', + 'WS-AL', + 'WS-AT', + 'WS-FA', + 'WS-GE', + 'WS-GI', + 'WS-PA', + 'WS-SA', + 'WS-TU', + 'WS-VF', + 'WS-VS', + 'YE-AB', + 'YE-AD', + 'YE-AM', + 'YE-BA', + 'YE-DA', + 'YE-DH', + 'YE-HD', + 'YE-HJ', + 'YE-HU', + 'YE-IB', + 'YE-JA', + 'YE-LA', + 'YE-MA', + 'YE-MR', + 'YE-MW', + 'YE-SD', + 'YE-SH', + 'YE-SN', + 'YE-TA', + 'YU-CG', + 'YU-KM', + 'YU-SR', + 'YU-VO', + 'ZA-EC', + 'ZA-FS', + 'ZA-GT', + 'ZA-MP', + 'ZA-NC', + 'ZA-NL', + 'ZA-NP', + 'ZA-NW', + 'ZA-WC', + 'ZM-01', + 'ZM-02', + 'ZM-03', + 'ZM-04', + 'ZM-05', + 'ZM-06', + 'ZM-07', + 'ZM-08', + 'ZM-09', + 'ZW-BU', + 'ZW-HA', + 'ZW-MA', + 'ZW-MC', + 'ZW-ME', + 'ZW-MI', + 'ZW-MN', + 'ZW-MS', + 'ZW-MV', + 'ZW-MW', + ), +) + +Subdivision.__doc__ = """ +Subvidision country codes from ISO 3166-2. + +Taken from `here `_. +""" + + +class Layouts(Enum): + """Keyboard layouts. Taken from Debian's 9 + /usr/share/X11/xkb/rules/evdev.lst. + """ + + US = 'English (US)' + AF = 'Afghani' + ARA = 'Arabic' + AL = 'Albanian' + AM = 'Armenian' + AT = 'German (Austria)' + AU = 'English (Australian)' + AZ = 'Azerbaijani' + BY = 'Belarusian' + BE = 'Belgian' + BD = 'Bangla' + BA = 'Bosnian' + BR = 'Portuguese (Brazil)' + BG = 'Bulgarian' + DZ = 'Berber (Algeria, Latin characters)' + MA = 'Arabic (Morocco)' + CM = 'English (Cameroon)' + MM = 'Burmese' + CA = 'French (Canada)' + CD = 'French (Democratic Republic of the Congo)' + CN = 'Chinese' + HR = 'Croatian' + CZ = 'Czech' + DK = 'Danish' + NL = 'Dutch' + BT = 'Dzongkha' + EE = 'Estonian' + IR = 'Persian' + IQ = 'Iraqi' + FO = 'Faroese' + FI = 'Finnish' + FR = 'French' + GH = 'English (Ghana)' + GN = 'French (Guinea)' + GE = 'Georgian' + DE = 'German' + GR = 'Greek' + HU = 'Hungarian' + IL = 'Hebrew' + IT = 'Italian' + JP = 'Japanese' + KG = 'Kyrgyz' + KH = 'Khmer (Cambodia)' + KZ = 'Kazakh' + LA = 'Lao' + LATAM = 'Spanish (Latin American)' + LT = 'Lithuanian' + LV = 'Latvian' + MAO = 'Maori' + ME = 'Montenegrin' + MK = 'Macedonian' + MT = 'Maltese' + MN = 'Mongolian' + NO = 'Norwegian' + PL = 'Polish' + PT = 'Portuguese' + RO = 'Romanian' + RU = 'Russian' + RS = 'Serbian' + SI = 'Slovenian' + SK = 'Slovak' + ES = 'Spanish' + SE = 'Swedish' + CH = 'German (Switzerland)' + SY = 'Arabic (Syria)' + TJ = 'Tajik' + LK = 'Sinhala (phonetic)' + TH = 'Thai' + TR = 'Turkish' + TW = 'Taiwanese' + UA = 'Ukrainian' + GB = 'English (UK)' + UZ = 'Uzbek' + VN = 'Vietnamese' + KR = 'Korean' + IE = 'Irish' + PK = 'Urdu (Pakistan)' + MV = 'Dhivehi' + ZA = 'English (South Africa)' + EPO = 'Esperanto' + NP = 'Nepali' + NG = 'English (Nigeria)' + ET = 'Amharic' + SN = 'Wolof' + BRAI = 'Braille' + TM = 'Turkmen' + ML = 'Bambara' + TZ = 'Swahili (Tanzania)' + TG = 'French (Togo)' + KE = 'Swahili (Kenya)' + BW = 'Tswana' + PH = 'Filipino' + MD = 'Moldavian' + ID = 'Indonesian (Jawi)' + MY = 'Malay (Jawi)' + BN = 'Malay (Jawi)' + IN = 'Indian' + IS = 'Icelandic' + NEC_VNDR_JP = 'Japanese (PC-98xx Series)' + + def __str__(self): + return self.value diff --git a/ereuse_devicehub/teal/json_util.py b/ereuse_devicehub/teal/json_util.py new file mode 100644 index 00000000..0219fb99 --- /dev/null +++ b/ereuse_devicehub/teal/json_util.py @@ -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) diff --git a/ereuse_devicehub/teal/marshmallow.py b/ereuse_devicehub/teal/marshmallow.py new file mode 100644 index 00000000..af5f0eca --- /dev/null +++ b/ereuse_devicehub/teal/marshmallow.py @@ -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] diff --git a/ereuse_devicehub/teal/query.py b/ereuse_devicehub/teal/query.py new file mode 100644 index 00000000..f1e3f370 --- /dev/null +++ b/ereuse_devicehub/teal/query.py @@ -0,0 +1,294 @@ +import json +from json import JSONDecodeError + +from ereuse_utils import flatten_mixed +from marshmallow import Schema as MarshmallowSchema +from marshmallow.fields import Boolean, Field, List, Nested, Str, missing_ +from sqlalchemy import Column, between, or_ +from webargs.flaskparser import FlaskParser + + +class ListQuery(List): + """Base class for list-based queries.""" + + def __init__(self, column: Column, cls_or_instance, **kwargs): + self.column = column + super().__init__(cls_or_instance, **kwargs) + + +class Between(ListQuery): + """ + Generates a `Between` SQL statement. + + This method wants the user to provide exactly two parameters: + min and max:: + + f = Between(Model.foo, Integer()) + ... + Query().loads({'f': [0, 100]} + + """ + + def _deserialize(self, value, attr, data): + l = super()._deserialize(value, attr, data) + return between(self.column, *l) + + +class Equal(Field): + """ + Generates an SQL equal ``==`` clause for a given column and value:: + + class MyArgs(Query): + f = Equal(MyModel.foo, Integer()) + MyArgs().load({'f': 24}) -> SQL: ``MyModel.foo == 24`` + + """ + + def __init__( + self, + column: Column, + field: Field, + default=missing_, + attribute=None, + data_key=None, + error=None, + validate=None, + required=False, + allow_none=None, + load_only=False, + dump_only=False, + missing=missing_, + error_messages=None, + **metadata, + ): + super().__init__( + default, + attribute, + data_key, + error, + validate, + required, + allow_none, + load_only, + dump_only, + missing, + error_messages, + **metadata, + ) + self.column = column + self.field = field + + def _deserialize(self, value, attr, data): + v = super()._deserialize(value, attr, data) + return self.column == self.field.deserialize(v) + + +class Or(List): + """ + Generates an `OR` SQL statement. This is like a Marshmallow List field, + so you can specify the type of value of the OR and validations. + + As an example, you can define with this a list of options:: + + f = Or(Equal(Model.foo, Str(validates=OneOf(['option1', 'option2']))) + + Where the user can select one or more:: + + {'f': ['option1']} + + And with ``Length`` you can enforce the user to only choose one option:: + + f = Or(..., validates=Length(equal=1)) + """ + + def _deserialize(self, value, attr, data): + l = super()._deserialize(value, attr, data) + return or_(v for v in l) + + +class ILike(Str): + """ + Generates a insensitive `LIKE` statement for strings. + """ + + def __init__( + self, + column: Column, + default=missing_, + attribute=None, + data_key=None, + error=None, + validate=None, + required=False, + allow_none=None, + load_only=False, + dump_only=False, + missing=missing_, + error_messages=None, + **metadata, + ): + super().__init__( + default, + attribute, + data_key, + error, + validate, + required, + allow_none, + load_only, + dump_only, + missing, + error_messages, + **metadata, + ) + self.column = column + + def _deserialize(self, value, attr, data): + v = super()._deserialize(value, attr, data) + return self.column.ilike('{}%'.format(v)) + + +class QueryField(Field): + """A field whose first parameter is a function that when + executed by passing only the value returns a SQLAlchemy query + expression. + """ + + def __init__( + self, + query, + field: Field, + default=missing_, + attribute=None, + data_key=None, + error=None, + validate=None, + required=False, + allow_none=None, + load_only=False, + dump_only=False, + missing=missing_, + error_messages=None, + **metadata, + ): + super().__init__( + default, + attribute, + data_key, + error, + validate, + required, + allow_none, + load_only, + dump_only, + missing, + error_messages, + **metadata, + ) + self.query = query + self.field = field + + def _deserialize(self, value, attr, data): + v = super()._deserialize(value, attr, data) + return self.query(v) + + +class Join(Nested): + # todo Joins are manual: they should be able to use ORM's join + def __init__( + self, join, nested, default=missing_, exclude=tuple(), only=None, **kwargs + ): + super().__init__(nested, default, exclude, only, **kwargs) + self.join = join + + def _deserialize(self, value, attr, data): + v = list(super()._deserialize(value, attr, data)) + v.append(self.join) + return v + + +class Query(MarshmallowSchema): + """ + A Marshmallow schema that outputs SQLAlchemy queries when ``loading`` + dictionaries:: + + class MyQuery(Query): + foo = Like(Mymodel.foocolumn) + + Mymodel.query.filter(*MyQuery().load({'foo': 'bar'})).all() + # Executes query SELECT ... WHERE foocolumn IS LIKE 'bar%' + + When used with ``webargs`` library you can pass generate queries + directly from the browser: ``foo.com/foo/?filter={'foo': 'bar'}``. + """ + + def load(self, data, many=None, partial=None): + """ + Flatten ``Nested`` ``Query`` and add the list of results to + a SQL ``AND``. + """ + values = super().load(data, many, partial).values() + return flatten_mixed(values) + + def dump(self, obj, many=None, update_fields=True): + raise NotImplementedError('Why would you want to dump a query?') + + +class Sort(MarshmallowSchema): + """ + A Marshmallow schema that outputs SQLAlchemy order clauses:: + + class MySort(Sort): + foo = SortField(MyModel.foocolumn) + MyModel.query.filter(...).order_by(*MyQuery().load({'foo': 0})).all() + + When used with ``webargs`` library you can pass generate sorts + directly from the browser: ``foo.com/foo/?sort={'foo': 1, 'bar': 0}``. + """ + + ASCENDING = True + """Sort in ascending order.""" + DESCENDING = False + """Sort in descending order.""" + + def load(self, data, many=None, partial=None): + values = super().load(data, many, partial).values() + return flatten_mixed(values) + + +class SortField(Boolean): + """A field that outputs a SQLAlchemy order clause.""" + + def __init__( + self, column: Column, truthy=Boolean.truthy, falsy=Boolean.falsy, **kwargs + ): + super().__init__(truthy, falsy, **kwargs) + self.column = column + + def _deserialize(self, value, attr, data): + v = super()._deserialize(value, attr, data) + return self.column.asc() if v else self.column.desc() + + +class NestedQueryFlaskParser(FlaskParser): + """ + Parses JSON-encoded URL parameters like + ``.../foo?param={"x": "y"}¶m2=["x", "y"]``, and it still allows + normal non-JSON-encoded params ``../foo?param=23¶m2={"a": "b"}``. + + You can keep a value always a string, regardless if it is a valid + JSON, by overriding the following method and setting per-case + actions by checking `name` property. + """ + + def parse_querystring(self, req, name, field): + v = super().parse_querystring(req, name, field) + try: + return json.loads(v) + except (JSONDecodeError, TypeError): + return v + + +class FullTextSearch(Str): + # todo this is dummy for now + pass diff --git a/ereuse_devicehub/teal/request.py b/ereuse_devicehub/teal/request.py new file mode 100644 index 00000000..f6b6c7fe --- /dev/null +++ b/ereuse_devicehub/teal/request.py @@ -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 diff --git a/ereuse_devicehub/teal/resource.py b/ereuse_devicehub/teal/resource.py new file mode 100644 index 00000000..31d33599 --- /dev/null +++ b/ereuse_devicehub/teal/resource.py @@ -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`_. + """ + 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 `_. + """ + # 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) diff --git a/ereuse_devicehub/teal/teal.py b/ereuse_devicehub/teal/teal.py new file mode 100644 index 00000000..7b341b49 --- /dev/null +++ b/ereuse_devicehub/teal/teal.py @@ -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 `_. + 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,) diff --git a/ereuse_devicehub/teal/utils.py b/ereuse_devicehub/teal/utils.py new file mode 100644 index 00000000..ec3668ca --- /dev/null +++ b/ereuse_devicehub/teal/utils.py @@ -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 diff --git a/tests/test_action.py b/tests/test_action.py index 01ca8ca6..7992bd21 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -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 diff --git a/tests/test_agent.py b/tests/test_agent.py index 9d5f379e..e65779cc 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -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', - tax_id='xyz', - country=Country.ES, - telephone=PhoneNumber('+34666666666'), - email='foo@bar.com') + person = Person( + name='Timmy', + tax_id='xyz', + country=Country.ES, + telephone=PhoneNumber('+34666666666'), + 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() diff --git a/tests/test_db.py b/tests/test_db.py index 92b345ee..d2085b61 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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): diff --git a/tests/test_device.py b/tests/test_device.py index 0d67c325..7d82ce55 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -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 diff --git a/tests/test_device_find.py b/tests/test_device_find.py index ae5eff5c..9b584be8 100644 --- a/tests/test_device_find.py +++ b/tests/test_device_find.py @@ -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({ - 'type': ['Computer', 'Laptop'], - 'manufacturer': 'Dell', - 'rating': { - 'rating': [3, 6], - 'appearance': [2, 4] - }, - 'tag': { - 'id': ['bcn-', 'activa-02'] + q = schema.load( + { + 'type': ['Computer', 'Laptop'], + 'manufacturer': 'Dell', + '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) ' \ - 'OR device.type IN (%(type_5)s))' in 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', - model='ml1', - manufacturer='mr1', - chassis=ComputerChassis.Tower), - Desktop(serial_number='2', - model='ml2', - manufacturer='mr2', - chassis=ComputerChassis.Microtower), - Laptop(serial_number='3', - model='ml3', - manufacturer='mr3', - chassis=ComputerChassis.Detachable), - Server(serial_number='4', - model='ml4', - manufacturer='mr4', - chassis=ComputerChassis.Tower), + Desktop( + serial_number='1', + model='ml1', + manufacturer='mr1', + chassis=ComputerChassis.Tower, + ), + Desktop( + serial_number='2', + model='ml2', + manufacturer='mr2', + chassis=ComputerChassis.Microtower, + ), + Laptop( + serial_number='3', + model='ml3', + manufacturer='mr3', + chassis=ComputerChassis.Detachable, + ), + Server( + serial_number='4', + model='ml4', + manufacturer='mr4', + 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=[ - ('sort', {'created': Sorting.DESCENDING}), - ('filter', {'type': ['Computer']}) - ]) + i, _ = user.get( + res=Device, + query=[ + ('sort', {'created': Sorting.DESCENDING}), + ('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({}, - res=Lot, - item='{}/children'.format(parent['id']), - query=[('id', child['id'])]) - i, _ = user.get(res=Device, query=[ - ('filter', {'type': ['Computer']}) - ]) + parent, _ = user.post( + {}, + res=Lot, + item='{}/children'.format(parent['id']), + 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({}, - res=Lot, - item='{}/devices'.format(parent['id']), - 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']]}}) - ]) + parent, _ = user.post( + {}, + res=Lot, + item='{}/devices'.format(parent['id']), + 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']]}})]) 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 ' \ - '2 from the child lot, with all their 2 components' + ), ( + 'The parent lot contains 2 items plus indirectly the other ' + '2 from the child lot, with all their 2 components' + ) - i, _ = user.get(res=Device, query=[ - ('filter', {'type': ['Computer'], 'lot': {'id': [parent['id']]}}), - ]) + 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.' diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 6501a01c..ca4f323b 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -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 diff --git a/tests/test_tag.py b/tests/test_tag.py index cbdd7e5f..14807d3c 100644 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -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 diff --git a/tests/test_user.py b/tests/test_user.py index 9fba986d..5a367611 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -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', - password='foo', - agent='Nice Person', - country=Country.ES.name, - telephone='+34 666 66 66 66', - tax_id='1234') + 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', + ) 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'}, - uri='/users/login/', - status=WrongCredentials) + client.post( + {'email': 'foo@foo.com', 'password': 'wrong pass'}, + uri='/users/login/', + 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'}, - uri='/users/login/', - status=ValidationError) + client.post( + {'email': 'this is not an email', 'password': 'nope'}, + uri='/users/login/', + status=ValidationError, + ) @pytest.mark.xfail(reason='Test not developed')