resolve conflict
This commit is contained in:
commit
4e610f0903
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -6,7 +6,34 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||||
ml).
|
ml).
|
||||||
|
|
||||||
## testing
|
## testing
|
||||||
|
|
||||||
|
## [2.5.2] - 2023-04-20
|
||||||
- [added] #414 add new vars in the settings file for wb.
|
- [added] #414 add new vars in the settings file for wb.
|
||||||
|
- [added] #440 add lots in export devices.
|
||||||
|
- [added] #441 allow remove documents.
|
||||||
|
- [added] #442 allow edit documents.
|
||||||
|
- [added] #443 add documents to devices.
|
||||||
|
- [added] #444 add new columns in list of documents.
|
||||||
|
- [changed] #439 move teal as internal module.
|
||||||
|
- [fixed] #437 replace names erasure by sanitization in templates.
|
||||||
|
|
||||||
|
## [2.5.1] - 2023-03-17
|
||||||
|
- [changed] #423 new hid.
|
||||||
|
- [changed] #426 new version of public page of device.
|
||||||
|
- [changed] #427 update links of terms and condotions.
|
||||||
|
- [changed] #428 only the data storage allow syncrinize, the rest are duplicate.
|
||||||
|
- [changed] #430 new version of erasure certificate.
|
||||||
|
- [fixed] #416 fix dhid in snapshot logs.
|
||||||
|
- [fixed] #419 fix settings version and template.
|
||||||
|
- [fixed] #420 not appear all lots in the dropdown menu for select the a lot.
|
||||||
|
- [fixed] #421 fix remove a placeholder from one old trade lot.
|
||||||
|
- [fixed] #422 fix simple datatables.
|
||||||
|
- [fixed] #424 fix new hid.
|
||||||
|
- [fixed] #431 fix forms for customer details.
|
||||||
|
- [fixed] #432 fix erasure certificate for a servers.
|
||||||
|
- [fixed] #433 fix get the last incoming for show customer datas in certificate.
|
||||||
|
- [fixed] #434 fix reopen transfer.
|
||||||
|
- [fixed] #436 fix hid in erasure certificate.
|
||||||
|
|
||||||
## [2.5.0] - 2022-11-30
|
## [2.5.0] - 2022-11-30
|
||||||
- [added] #407 erasure section with tabs in top.
|
- [added] #407 erasure section with tabs in top.
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "2.5.0"
|
__version__ = "2.5.2"
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
from sqlalchemy.exc import DataError
|
from sqlalchemy.exc import DataError
|
||||||
from teal.auth import TokenAuth
|
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from werkzeug.exceptions import Unauthorized
|
from werkzeug.exceptions import Unauthorized
|
||||||
|
|
||||||
from ereuse_devicehub.resources.user.models import User, Session
|
from ereuse_devicehub.resources.user.models import Session, User
|
||||||
|
from ereuse_devicehub.teal.auth import TokenAuth
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
|
||||||
|
|
||||||
class Auth(TokenAuth):
|
class Auth(TokenAuth):
|
||||||
|
|
|
@ -2,21 +2,23 @@ import os
|
||||||
|
|
||||||
import click.testing
|
import click.testing
|
||||||
import flask.cli
|
import flask.cli
|
||||||
import ereuse_utils
|
import ereuse_devicehub.ereuse_utils
|
||||||
|
|
||||||
from ereuse_devicehub.config import DevicehubConfig
|
from ereuse_devicehub.config import DevicehubConfig
|
||||||
from ereuse_devicehub.devicehub import Devicehub
|
from ereuse_devicehub.devicehub import Devicehub
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.ps1 = '\001\033[92m\002>>> \001\033[0m\002'
|
sys.ps1 = '\001\033[92m\002>>> \001\033[0m\002'
|
||||||
sys.ps2= '\001\033[94m\002... \001\033[0m\002'
|
sys.ps2 = '\001\033[94m\002... \001\033[0m\002'
|
||||||
|
|
||||||
import os, readline, atexit
|
import os, readline, atexit
|
||||||
|
|
||||||
history_file = os.path.join(os.environ['HOME'], '.python_history')
|
history_file = os.path.join(os.environ['HOME'], '.python_history')
|
||||||
try:
|
try:
|
||||||
readline.read_history_file(history_file)
|
readline.read_history_file(history_file)
|
||||||
except IOError:
|
except IOError:
|
||||||
pass
|
pass
|
||||||
readline.parse_and_bind("tab: complete")
|
readline.parse_and_bind("tab: complete")
|
||||||
readline.parse_and_bind('"\e[5~": history-search-backward')
|
readline.parse_and_bind('"\e[5~": history-search-backward')
|
||||||
readline.parse_and_bind('"\e[6~": history-search-forward')
|
readline.parse_and_bind('"\e[6~": history-search-forward')
|
||||||
|
@ -29,6 +31,7 @@ readline.parse_and_bind('"\e[1;5D": backward-word')
|
||||||
readline.set_history_length(100000)
|
readline.set_history_length(100000)
|
||||||
atexit.register(readline.write_history_file, history_file)
|
atexit.register(readline.write_history_file, history_file)
|
||||||
|
|
||||||
|
|
||||||
class DevicehubGroup(flask.cli.FlaskGroup):
|
class DevicehubGroup(flask.cli.FlaskGroup):
|
||||||
# todo users cannot make cli to use a custom db this way!
|
# todo users cannot make cli to use a custom db this way!
|
||||||
CONFIG = DevicehubConfig
|
CONFIG = DevicehubConfig
|
||||||
|
@ -49,26 +52,37 @@ class DevicehubGroup(flask.cli.FlaskGroup):
|
||||||
def get_version(ctx, param, value):
|
def get_version(ctx, param, value):
|
||||||
if not value or ctx.resilient_parsing:
|
if not value or ctx.resilient_parsing:
|
||||||
return
|
return
|
||||||
click.echo('Devicehub {}'.format(ereuse_utils.version('ereuse-devicehub')), color=ctx.color)
|
click.echo(
|
||||||
|
'Devicehub {}'.format(
|
||||||
|
ereuse_devicehub.ereuse_utils.version('ereuse-devicehub')
|
||||||
|
),
|
||||||
|
color=ctx.color,
|
||||||
|
)
|
||||||
flask.cli.get_version(ctx, param, value)
|
flask.cli.get_version(ctx, param, value)
|
||||||
|
|
||||||
|
|
||||||
@click.option('--version',
|
@click.option(
|
||||||
help='Devicehub version.',
|
'--version',
|
||||||
expose_value=False,
|
help='Devicehub version.',
|
||||||
callback=get_version,
|
expose_value=False,
|
||||||
is_flag=True,
|
callback=get_version,
|
||||||
is_eager=True)
|
is_flag=True,
|
||||||
@click.group(cls=DevicehubGroup,
|
is_eager=True,
|
||||||
context_settings=Devicehub.cli_context_settings,
|
)
|
||||||
add_version_option=False,
|
@click.group(
|
||||||
help="""Manages the Devicehub of the inventory {}.
|
cls=DevicehubGroup,
|
||||||
|
context_settings=Devicehub.cli_context_settings,
|
||||||
|
add_version_option=False,
|
||||||
|
help="""Manages the Devicehub of the inventory {}.
|
||||||
|
|
||||||
Use 'export dhi=xx' to set the inventory that this CLI
|
Use 'export dhi=xx' to set the inventory that this CLI
|
||||||
manages. For example 'export dhi=db1' and then executing
|
manages. For example 'export dhi=db1' and then executing
|
||||||
'dh tag add' adds a tag in the db1 database. Operations
|
'dh tag add' adds a tag in the db1 database. Operations
|
||||||
that affect the common database (like creating an user)
|
that affect the common database (like creating an user)
|
||||||
are not affected by this.
|
are not affected by this.
|
||||||
""".format(os.environ.get('dhi')))
|
""".format(
|
||||||
|
os.environ.get('dhi')
|
||||||
|
),
|
||||||
|
)
|
||||||
def cli():
|
def cli():
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
from inspect import isclass
|
from inspect import isclass
|
||||||
from typing import Dict, Iterable, Type, Union
|
from typing import Dict, Iterable, Type, Union
|
||||||
|
|
||||||
from ereuse_utils.test import JSON, Res
|
from ereuse_devicehub.ereuse_utils.test import JSON, Res
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
from flask_wtf.csrf import generate_csrf
|
from flask_wtf.csrf import generate_csrf
|
||||||
from teal.client import Client as TealClient
|
|
||||||
from teal.client import Query, Status
|
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from ereuse_devicehub.resources import models, schemas
|
from ereuse_devicehub.resources import models, schemas
|
||||||
|
from ereuse_devicehub.teal.client import Client as TealClient
|
||||||
|
from ereuse_devicehub.teal.client import Query, Status
|
||||||
|
|
||||||
ResourceLike = Union[Type[Union[models.Thing, schemas.Thing]], str]
|
ResourceLike = Union[Type[Union[models.Thing, schemas.Thing]], str]
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,6 @@ from distutils.version import StrictVersion
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
from decouple import config
|
from decouple import config
|
||||||
from teal.auth import TokenAuth
|
|
||||||
from teal.config import Config
|
|
||||||
from teal.enums import Currency
|
|
||||||
from teal.utils import import_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources import (
|
from ereuse_devicehub.resources import (
|
||||||
action,
|
action,
|
||||||
|
@ -24,6 +20,10 @@ from ereuse_devicehub.resources.licences import licences
|
||||||
from ereuse_devicehub.resources.metric import definitions as metric_def
|
from ereuse_devicehub.resources.metric import definitions as metric_def
|
||||||
from ereuse_devicehub.resources.tradedocument import definitions as tradedocument
|
from ereuse_devicehub.resources.tradedocument import definitions as tradedocument
|
||||||
from ereuse_devicehub.resources.versions import versions
|
from ereuse_devicehub.resources.versions import versions
|
||||||
|
from ereuse_devicehub.teal.auth import TokenAuth
|
||||||
|
from ereuse_devicehub.teal.config import Config
|
||||||
|
from ereuse_devicehub.teal.enums import Currency
|
||||||
|
from ereuse_devicehub.teal.utils import import_resource
|
||||||
|
|
||||||
|
|
||||||
class DevicehubConfig(Config):
|
class DevicehubConfig(Config):
|
||||||
|
|
|
@ -4,7 +4,8 @@ from sqlalchemy.dialects import postgresql
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.sql import expression
|
from sqlalchemy.sql import expression
|
||||||
from sqlalchemy_utils import view
|
from sqlalchemy_utils import view
|
||||||
from teal.db import SchemaSQLAlchemy, SchemaSession
|
|
||||||
|
from ereuse_devicehub.teal.db import SchemaSession, SchemaSQLAlchemy
|
||||||
|
|
||||||
|
|
||||||
class DhSession(SchemaSession):
|
class DhSession(SchemaSession):
|
||||||
|
@ -23,6 +24,7 @@ class DhSession(SchemaSession):
|
||||||
# flush, all the new / dirty interesting things in a variable
|
# flush, all the new / dirty interesting things in a variable
|
||||||
# until DeviceSearch is executed
|
# until DeviceSearch is executed
|
||||||
from ereuse_devicehub.resources.device.search import DeviceSearch
|
from ereuse_devicehub.resources.device.search import DeviceSearch
|
||||||
|
|
||||||
DeviceSearch.update_modified_devices(session=self)
|
DeviceSearch.update_modified_devices(session=self)
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,6 +33,7 @@ class SQLAlchemy(SchemaSQLAlchemy):
|
||||||
schema of the database, as it is in the `search_path`
|
schema of the database, as it is in the `search_path`
|
||||||
defined in teal.
|
defined in teal.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# todo add here all types of columns used so we don't have to
|
# todo add here all types of columns used so we don't have to
|
||||||
# manually import them all the time
|
# manually import them all the time
|
||||||
UUID = postgresql.UUID
|
UUID = postgresql.UUID
|
||||||
|
@ -60,7 +63,9 @@ def create_view(name, selectable):
|
||||||
# We need to ensure views are created / destroyed before / after
|
# We need to ensure views are created / destroyed before / after
|
||||||
# SchemaSQLAlchemy's listeners execute
|
# SchemaSQLAlchemy's listeners execute
|
||||||
# That is why insert=True in 'after_create'
|
# That is why insert=True in 'after_create'
|
||||||
event.listen(db.metadata, 'after_create', view.CreateView(name, selectable), insert=True)
|
event.listen(
|
||||||
|
db.metadata, 'after_create', view.CreateView(name, selectable), insert=True
|
||||||
|
)
|
||||||
event.listen(db.metadata, 'before_drop', view.DropView(name))
|
event.listen(db.metadata, 'before_drop', view.DropView(name))
|
||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,11 @@ from typing import Type
|
||||||
import boltons.urlutils
|
import boltons.urlutils
|
||||||
import click
|
import click
|
||||||
import click_spinner
|
import click_spinner
|
||||||
import ereuse_utils.cli
|
import ereuse_devicehub.ereuse_utils.cli
|
||||||
from ereuse_utils.session import DevicehubClient
|
from ereuse_devicehub.ereuse_utils.session import DevicehubClient
|
||||||
from flask import _app_ctx_stack, g
|
from flask import _app_ctx_stack, g
|
||||||
from flask_login import LoginManager, current_user
|
from flask_login import LoginManager, current_user
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from teal.db import ResourceNotFound, SchemaSQLAlchemy
|
|
||||||
from teal.teal import Teal
|
|
||||||
|
|
||||||
from ereuse_devicehub.auth import Auth
|
from ereuse_devicehub.auth import Auth
|
||||||
from ereuse_devicehub.client import Client, UserClient
|
from ereuse_devicehub.client import Client, UserClient
|
||||||
|
@ -24,6 +22,8 @@ from ereuse_devicehub.dummy.dummy import Dummy
|
||||||
from ereuse_devicehub.resources.device.search import DeviceSearch
|
from ereuse_devicehub.resources.device.search import DeviceSearch
|
||||||
from ereuse_devicehub.resources.inventory import Inventory, InventoryDef
|
from ereuse_devicehub.resources.inventory import Inventory, InventoryDef
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound, SchemaSQLAlchemy
|
||||||
|
from ereuse_devicehub.teal.teal import Teal
|
||||||
from ereuse_devicehub.templating import Environment
|
from ereuse_devicehub.templating import Environment
|
||||||
|
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ class Devicehub(Teal):
|
||||||
@click.option(
|
@click.option(
|
||||||
'--tag-url',
|
'--tag-url',
|
||||||
'-tu',
|
'-tu',
|
||||||
type=ereuse_utils.cli.URL(scheme=True, host=True, path=False),
|
type=ereuse_devicehub.ereuse_utils.cli.URL(scheme=True, host=True, path=False),
|
||||||
default='http://example.com',
|
default='http://example.com',
|
||||||
help='The base url (scheme and host) of the tag provider.',
|
help='The base url (scheme and host) of the tag provider.',
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,10 +5,10 @@ from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import click_spinner
|
import click_spinner
|
||||||
import ereuse_utils.cli
|
|
||||||
import jwt
|
import jwt
|
||||||
import yaml
|
import yaml
|
||||||
from ereuse_utils.test import ANY
|
from ereuse_devicehub.ereuse_utils.test import ANY
|
||||||
|
from ereuse_devicehub import ereuse_utils
|
||||||
|
|
||||||
from ereuse_devicehub.client import UserClient
|
from ereuse_devicehub.client import UserClient
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
|
173
ereuse_devicehub/ereuse_utils/__init__.py
Normal file
173
ereuse_devicehub/ereuse_utils/__init__.py
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
import enum
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import locale
|
||||||
|
from collections import Iterable
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from distutils.version import StrictVersion
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Generator, Union
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
class JSONEncoder(json.JSONEncoder):
|
||||||
|
"""An overloaded JSON Encoder with extra type support."""
|
||||||
|
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, enum.Enum):
|
||||||
|
return obj.name
|
||||||
|
elif isinstance(obj, datetime):
|
||||||
|
return obj.isoformat()
|
||||||
|
elif isinstance(obj, timedelta):
|
||||||
|
return round(obj.total_seconds())
|
||||||
|
elif isinstance(obj, UUID):
|
||||||
|
return str(obj)
|
||||||
|
elif isinstance(obj, StrictVersion):
|
||||||
|
return str(obj)
|
||||||
|
elif isinstance(obj, set):
|
||||||
|
return list(obj)
|
||||||
|
elif isinstance(obj, Decimal):
|
||||||
|
return float(obj)
|
||||||
|
elif isinstance(obj, Dumpeable):
|
||||||
|
return obj.dump()
|
||||||
|
elif isinstance(obj, ipaddress._BaseAddress):
|
||||||
|
return str(obj)
|
||||||
|
# Instead of failing, return the string representation by default
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class Dumpeable:
|
||||||
|
"""Dumps dictionaries and jsons for Devicehub.
|
||||||
|
|
||||||
|
A base class to allow subclasses to generate dictionaries
|
||||||
|
and json suitable for sending to a Devicehub, i.e. preventing
|
||||||
|
private and constants to be in the JSON and camelCases field names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENCODER = JSONEncoder
|
||||||
|
|
||||||
|
def dump(self):
|
||||||
|
"""
|
||||||
|
Creates a dictionary consisting of the
|
||||||
|
non-private fields of this instance with camelCase field names.
|
||||||
|
"""
|
||||||
|
import inflection
|
||||||
|
|
||||||
|
return {
|
||||||
|
inflection.camelize(name, uppercase_first_letter=False): getattr(self, name)
|
||||||
|
for name in self._field_names()
|
||||||
|
if not name.startswith('_') and not name[0].isupper()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _field_names(self):
|
||||||
|
"""An iterable of the names to dump."""
|
||||||
|
# Feel free to override this
|
||||||
|
return vars(self).keys()
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
"""
|
||||||
|
Creates a JSON representation of the non-private fields of
|
||||||
|
this class.
|
||||||
|
"""
|
||||||
|
return json.dumps(self, cls=self.ENCODER, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
class DumpeableModel(Dumpeable):
|
||||||
|
"""A dumpeable for SQLAlchemy models.
|
||||||
|
|
||||||
|
Note that this does not avoid recursive relations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _field_names(self):
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
return (a.key for a in inspect(self).attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_utf8(app_name_to_show_on_error: str):
|
||||||
|
"""
|
||||||
|
Python3 uses by default the system set, but it expects it to be
|
||||||
|
‘utf-8’ to work correctly.
|
||||||
|
This can generate problems in reading and writing files and in
|
||||||
|
``.decode()`` method.
|
||||||
|
|
||||||
|
An example how to 'fix' it::
|
||||||
|
|
||||||
|
echo 'export LC_CTYPE=en_US.UTF-8' > .bash_profile
|
||||||
|
echo 'export LC_ALL=en_US.UTF-8' > .bash_profile
|
||||||
|
"""
|
||||||
|
encoding = locale.getpreferredencoding()
|
||||||
|
if encoding.lower() != 'utf-8':
|
||||||
|
raise OSError(
|
||||||
|
'{} works only in UTF-8, but yours is set at {}'
|
||||||
|
''.format(app_name_to_show_on_error, encoding)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> datetime:
|
||||||
|
"""
|
||||||
|
Returns a compatible 'now' with DeviceHub's API,
|
||||||
|
this is as UTC and without microseconds.
|
||||||
|
"""
|
||||||
|
return datetime.utcnow().replace(microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_mixed(values: Iterable) -> Generator:
|
||||||
|
"""
|
||||||
|
Flatten a list containing lists and other elements. This is not deep.
|
||||||
|
|
||||||
|
>>> list(flatten_mixed([1, 2, [3, 4]]))
|
||||||
|
[1, 2, 3, 4]
|
||||||
|
"""
|
||||||
|
for x in values:
|
||||||
|
if isinstance(x, list):
|
||||||
|
for y in x:
|
||||||
|
yield y
|
||||||
|
else:
|
||||||
|
yield x
|
||||||
|
|
||||||
|
|
||||||
|
def if_none_return_none(f):
|
||||||
|
"""If the first value is None return None, otherwise execute f."""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(self, value, *args, **kwargs):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return f(self, value, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def local_ip(
|
||||||
|
dest='109.69.8.152',
|
||||||
|
) -> Union[ipaddress.IPv4Address, ipaddress.IPv6Address]:
|
||||||
|
"""Gets the local IP of the interface that has access to the
|
||||||
|
Internet.
|
||||||
|
|
||||||
|
This is a reliable way to test if a device has an active
|
||||||
|
connection to the Internet.
|
||||||
|
|
||||||
|
This method works by connecting, by default,
|
||||||
|
to the IP of ereuse01.ereuse.org.
|
||||||
|
|
||||||
|
>>> local_ip()
|
||||||
|
|
||||||
|
:raise OSError: The device cannot connect to the Internet.
|
||||||
|
"""
|
||||||
|
import socket, ipaddress
|
||||||
|
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect((dest, 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
return ipaddress.ip_address(ip)
|
||||||
|
|
||||||
|
|
||||||
|
def version(package_name: str) -> StrictVersion:
|
||||||
|
"""Returns the version of a package name installed with pip."""
|
||||||
|
# From https://stackoverflow.com/a/2073599
|
||||||
|
import pkg_resources
|
||||||
|
|
||||||
|
return StrictVersion(pkg_resources.require(package_name)[0].version)
|
301
ereuse_devicehub/ereuse_utils/cli.py
Normal file
301
ereuse_devicehub/ereuse_utils/cli.py
Normal file
|
@ -0,0 +1,301 @@
|
||||||
|
import enum as _enum
|
||||||
|
import getpass
|
||||||
|
import itertools
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from time import sleep
|
||||||
|
from typing import Any, Iterable, Type
|
||||||
|
|
||||||
|
from boltons import urlutils
|
||||||
|
from click import types as click_types
|
||||||
|
from colorama import Fore
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from ereuse_devicehub.ereuse_utils import if_none_return_none
|
||||||
|
|
||||||
|
COMMON_CONTEXT_S = {'help_option_names': ('-h', '--help')}
|
||||||
|
"""Common Context settings used for our implementations of the
|
||||||
|
Click cli.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Py2/3 compat. Empty conditional to avoid coverage
|
||||||
|
try:
|
||||||
|
_unicode = unicode
|
||||||
|
except NameError:
|
||||||
|
_unicode = str
|
||||||
|
|
||||||
|
|
||||||
|
class Enum(click_types.Choice):
|
||||||
|
"""
|
||||||
|
Enum support for click.
|
||||||
|
|
||||||
|
Use it as a collection: @click.option(..., type=cli.Enum(MyEnum)).
|
||||||
|
Then, this expects you to pass the *name* of a member of the enum.
|
||||||
|
|
||||||
|
From `this github issue <https://github.com/pallets/click/issues/
|
||||||
|
605#issuecomment-277539425>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, enum: Type[_enum.Enum]):
|
||||||
|
self.__enum = enum
|
||||||
|
super().__init__(enum.__members__)
|
||||||
|
|
||||||
|
def convert(self, value, param, ctx):
|
||||||
|
return self.__enum[super().convert(value, param, ctx)]
|
||||||
|
|
||||||
|
|
||||||
|
class Path(click_types.Path):
|
||||||
|
"""Like click.Path but returning ``pathlib.Path`` objects."""
|
||||||
|
|
||||||
|
def convert(self, value, param, ctx):
|
||||||
|
return pathlib.Path(super().convert(value, param, ctx))
|
||||||
|
|
||||||
|
|
||||||
|
class URL(click_types.StringParamType):
|
||||||
|
"""Returns a bolton's URL."""
|
||||||
|
|
||||||
|
name = 'url'
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
scheme=None,
|
||||||
|
username=None,
|
||||||
|
password=None,
|
||||||
|
host=None,
|
||||||
|
port=None,
|
||||||
|
path=None,
|
||||||
|
query_params=None,
|
||||||
|
fragment=None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
"""Creates the type URL. You can require or enforce parts
|
||||||
|
of the URL by setting parameters of this constructor.
|
||||||
|
|
||||||
|
If the param is...
|
||||||
|
|
||||||
|
- None, no check is performed (default).
|
||||||
|
- True, it is then required as part of the URL.
|
||||||
|
- False, it is then required NOT to be part of the URL.
|
||||||
|
- Any other value, then such value is required to be in
|
||||||
|
the URL.
|
||||||
|
"""
|
||||||
|
self.attrs = (
|
||||||
|
('scheme', scheme),
|
||||||
|
('username', username),
|
||||||
|
('password', password),
|
||||||
|
('host', host),
|
||||||
|
('port', port),
|
||||||
|
('path', path),
|
||||||
|
('query_params', query_params),
|
||||||
|
('fragment', fragment),
|
||||||
|
)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def convert(self, value, param, ctx):
|
||||||
|
url = urlutils.URL(super().convert(value, param, ctx))
|
||||||
|
for name, attr in self.attrs:
|
||||||
|
if attr is True:
|
||||||
|
if not getattr(url, name):
|
||||||
|
self.fail(
|
||||||
|
'URL {} must contain {} but it does not.'.format(url, name)
|
||||||
|
)
|
||||||
|
elif attr is False:
|
||||||
|
if getattr(url, name):
|
||||||
|
self.fail('URL {} cannot contain {} but it does.'.format(url, name))
|
||||||
|
elif attr:
|
||||||
|
if getattr(url, name) != attr:
|
||||||
|
self.fail('{} form {} can only be {}'.format(name, url, attr))
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def password(service: str, username: str, prompt: str = 'Password:') -> str:
|
||||||
|
"""Gets a password from the keyring or the terminal."""
|
||||||
|
import keyring
|
||||||
|
|
||||||
|
return keyring.get_password(service, username) or getpass.getpass(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
class Line(tqdm):
|
||||||
|
spinner_cycle = itertools.cycle(['-', '/', '|', '\\'])
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
total=None,
|
||||||
|
desc=None,
|
||||||
|
leave=True,
|
||||||
|
file=None,
|
||||||
|
ncols=None,
|
||||||
|
mininterval=0.2,
|
||||||
|
maxinterval=10.0,
|
||||||
|
miniters=None,
|
||||||
|
ascii=None,
|
||||||
|
disable=False,
|
||||||
|
unit='it',
|
||||||
|
unit_scale=False,
|
||||||
|
dynamic_ncols=True,
|
||||||
|
smoothing=0.3,
|
||||||
|
bar_format=None,
|
||||||
|
initial=0,
|
||||||
|
position=None,
|
||||||
|
postfix=None,
|
||||||
|
unit_divisor=1000,
|
||||||
|
write_bytes=None,
|
||||||
|
gui=False,
|
||||||
|
close_message: Iterable = None,
|
||||||
|
error_message: Iterable = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""This cannot work with iterables. Iterable use is considered
|
||||||
|
backward-compatibility in tqdm and inconsistent in Line.
|
||||||
|
Manually call ``update``.
|
||||||
|
"""
|
||||||
|
self._close_message = close_message
|
||||||
|
self._error_message = error_message
|
||||||
|
if total:
|
||||||
|
bar_format = '{desc}{percentage:.1f}% |{bar}| {n:1g}/{total:1g} {elapsed}<{remaining}'
|
||||||
|
super().__init__(
|
||||||
|
None,
|
||||||
|
desc,
|
||||||
|
total,
|
||||||
|
leave,
|
||||||
|
file,
|
||||||
|
ncols,
|
||||||
|
mininterval,
|
||||||
|
maxinterval,
|
||||||
|
miniters,
|
||||||
|
ascii,
|
||||||
|
disable,
|
||||||
|
unit,
|
||||||
|
unit_scale,
|
||||||
|
dynamic_ncols,
|
||||||
|
smoothing,
|
||||||
|
bar_format,
|
||||||
|
initial,
|
||||||
|
position,
|
||||||
|
postfix,
|
||||||
|
unit_divisor,
|
||||||
|
write_bytes,
|
||||||
|
gui,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_at_line(self, *args):
|
||||||
|
self.clear()
|
||||||
|
with self._lock:
|
||||||
|
self.display(''.join(str(arg) for arg in args))
|
||||||
|
|
||||||
|
def close_message(self, *args):
|
||||||
|
self._close_message = args
|
||||||
|
|
||||||
|
def error_message(self, *args):
|
||||||
|
self._error_message = args
|
||||||
|
|
||||||
|
def close(self): # noqa: C901
|
||||||
|
"""
|
||||||
|
Cleanup and (if leave=False) close the progressbar.
|
||||||
|
"""
|
||||||
|
if self.disable:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Prevent multiple closures
|
||||||
|
self.disable = True
|
||||||
|
|
||||||
|
# decrement instance pos and remove from internal set
|
||||||
|
pos = abs(self.pos)
|
||||||
|
self._decr_instances(self)
|
||||||
|
|
||||||
|
# GUI mode
|
||||||
|
if not hasattr(self, "sp"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# annoyingly, _supports_unicode isn't good enough
|
||||||
|
def fp_write(s):
|
||||||
|
self.fp.write(_unicode(s))
|
||||||
|
|
||||||
|
try:
|
||||||
|
fp_write('')
|
||||||
|
except ValueError as e:
|
||||||
|
if 'closed' in str(e):
|
||||||
|
return
|
||||||
|
raise # pragma: no cover
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self.leave:
|
||||||
|
if self._close_message:
|
||||||
|
self.display(
|
||||||
|
''.join(str(arg) for arg in self._close_message), pos=pos
|
||||||
|
)
|
||||||
|
elif self.last_print_n < self.n:
|
||||||
|
# stats for overall rate (no weighted average)
|
||||||
|
self.avg_time = None
|
||||||
|
self.display(pos=pos)
|
||||||
|
if not max(
|
||||||
|
[abs(getattr(i, "pos", 0)) for i in self._instances] + [pos]
|
||||||
|
):
|
||||||
|
# only if not nested (#477)
|
||||||
|
fp_write('\n')
|
||||||
|
else:
|
||||||
|
if self._close_message:
|
||||||
|
self.display(
|
||||||
|
''.join(str(arg) for arg in self._close_message), pos=pos
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.display(msg='', pos=pos)
|
||||||
|
if not pos:
|
||||||
|
fp_write('\r')
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def spin(self, prefix: str):
|
||||||
|
self._stop_running = threading.Event()
|
||||||
|
spin_thread = threading.Thread(target=self._spin, args=[prefix])
|
||||||
|
spin_thread.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._stop_running.set()
|
||||||
|
spin_thread.join()
|
||||||
|
|
||||||
|
def _spin(self, prefix: str):
|
||||||
|
while not self._stop_running.is_set():
|
||||||
|
self.write_at_line(prefix, next(self.spinner_cycle))
|
||||||
|
sleep(0.50)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@contextmanager
|
||||||
|
def reserve_lines(self, n):
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.move_down(n - 1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def move_down(cls, n: int):
|
||||||
|
print('\n' * n)
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
if exc[0]:
|
||||||
|
self._close_message = self._error_message
|
||||||
|
return super().__exit__(*exc)
|
||||||
|
|
||||||
|
|
||||||
|
def clear():
|
||||||
|
os.system('clear')
|
||||||
|
|
||||||
|
|
||||||
|
def title(text: Any, ljust=32) -> str:
|
||||||
|
# Note that is 38 px + 1 extra space = 39 min
|
||||||
|
return str(text).ljust(ljust) + ' '
|
||||||
|
|
||||||
|
|
||||||
|
def danger(text: Any) -> str:
|
||||||
|
return '{}{}{}'.format(Fore.RED, text, Fore.RESET)
|
||||||
|
|
||||||
|
|
||||||
|
def warning(text: Any) -> str:
|
||||||
|
return '{}{}{}'.format(Fore.YELLOW, text, Fore.RESET)
|
||||||
|
|
||||||
|
|
||||||
|
def done(text: Any = 'done.') -> str:
|
||||||
|
return '{}{}{}'.format(Fore.GREEN, text, Fore.RESET)
|
148
ereuse_devicehub/ereuse_utils/cmd.py
Normal file
148
ereuse_devicehub/ereuse_utils/cmd.py
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import subprocess
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Any, Set
|
||||||
|
|
||||||
|
from ereuse_devicehub.ereuse_utils import text
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
*cmd: Any,
|
||||||
|
out=subprocess.PIPE,
|
||||||
|
err=subprocess.DEVNULL,
|
||||||
|
to_string=True,
|
||||||
|
check=True,
|
||||||
|
shell=False,
|
||||||
|
**kwargs,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""subprocess.run with a better API.
|
||||||
|
|
||||||
|
:param cmd: A list of commands to execute as parameters.
|
||||||
|
Parameters will be passed-in to ``str()`` so they
|
||||||
|
can be any object that can handle str().
|
||||||
|
:param out: As ``subprocess.run.stdout``.
|
||||||
|
:param err: As ``subprocess.run.stderr``.
|
||||||
|
:param to_string: As ``subprocess.run.universal_newlines``.
|
||||||
|
:param check: As ``subprocess.run.check``.
|
||||||
|
:param shell:
|
||||||
|
:param kwargs: Any other parameters that ``subprocess.run``
|
||||||
|
accepts.
|
||||||
|
:return: The result of executing ``subprocess.run``.
|
||||||
|
"""
|
||||||
|
cmds = tuple(str(c) for c in cmd)
|
||||||
|
return subprocess.run(
|
||||||
|
' '.join(cmds) if shell else cmds,
|
||||||
|
stdout=out,
|
||||||
|
stderr=err,
|
||||||
|
universal_newlines=to_string,
|
||||||
|
check=check,
|
||||||
|
shell=shell,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressiveCmd:
|
||||||
|
"""Executes a cmd while interpreting its completion percentage.
|
||||||
|
|
||||||
|
The completion percentage of the cmd is stored in
|
||||||
|
:attr:`.percentage` and the user can obtain percentage
|
||||||
|
increments by executing :meth:`.increment`.
|
||||||
|
|
||||||
|
This class is useful to use within a child thread, so a main
|
||||||
|
thread can request from time to time the percentage / increment
|
||||||
|
status of the running command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ_LINE = None
|
||||||
|
DECIMALS = {4, 5, 6}
|
||||||
|
DECIMAL_NUMBERS = 2
|
||||||
|
INT = {1, 2, 3}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*cmd: Any,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
number_chars: Set[int] = INT,
|
||||||
|
decimal_numbers: int = None,
|
||||||
|
read: int = READ_LINE,
|
||||||
|
callback=None,
|
||||||
|
check=True,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
:param cmd: The command to execute.
|
||||||
|
:param stderr: the stderr passed-in to Popen.
|
||||||
|
:param stdout: the stdout passed-in to Popen
|
||||||
|
:param number_chars: The number of chars used to represent
|
||||||
|
the percentage. Normalized cases are
|
||||||
|
:attr:`.DECIMALS` and :attr:`.INT`.
|
||||||
|
:param read: For commands that do not print lines, how many
|
||||||
|
characters we should read between updates.
|
||||||
|
The percentage should be between those
|
||||||
|
characters.
|
||||||
|
:param callback: If passed in, this method is executed every time
|
||||||
|
run gets an update from the command, passing
|
||||||
|
in the increment from the last execution.
|
||||||
|
If not passed-in, you can get such increment
|
||||||
|
by executing manually the ``increment`` method.
|
||||||
|
:param check: Raise error if subprocess return code is non-zero.
|
||||||
|
"""
|
||||||
|
self.cmd = tuple(str(c) for c in cmd)
|
||||||
|
self.read = read
|
||||||
|
self.step = 0
|
||||||
|
self.check = check
|
||||||
|
self.number_chars = number_chars
|
||||||
|
self.decimal_numbers = decimal_numbers
|
||||||
|
# We call subprocess in the main thread so the main thread
|
||||||
|
# can react on ``CalledProcessError`` exceptions
|
||||||
|
self.conn = conn = subprocess.Popen(
|
||||||
|
self.cmd, universal_newlines=True, stderr=subprocess.PIPE, stdout=stdout
|
||||||
|
)
|
||||||
|
self.out = conn.stdout if stdout == subprocess.PIPE else conn.stderr
|
||||||
|
self._callback = callback
|
||||||
|
self.last_update_percentage = 0
|
||||||
|
self.percentage = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def percentage(self):
|
||||||
|
return self._percentage
|
||||||
|
|
||||||
|
@percentage.setter
|
||||||
|
def percentage(self, v):
|
||||||
|
self._percentage = v
|
||||||
|
if self._callback and self._percentage > 0:
|
||||||
|
increment = self.increment()
|
||||||
|
if (
|
||||||
|
increment > 0
|
||||||
|
): # Do not bother calling if there has not been any increment
|
||||||
|
self._callback(increment, self._percentage)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Processes the output."""
|
||||||
|
while True:
|
||||||
|
out = self.out.read(self.read) if self.read else self.out.readline()
|
||||||
|
if out:
|
||||||
|
with suppress(StopIteration):
|
||||||
|
self.percentage = next(
|
||||||
|
text.positive_percentages(
|
||||||
|
out, self.number_chars, self.decimal_numbers
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else: # No more output
|
||||||
|
break
|
||||||
|
return_code = self.conn.wait() # wait until cmd ends
|
||||||
|
if self.check and return_code != 0:
|
||||||
|
raise subprocess.CalledProcessError(
|
||||||
|
self.conn.returncode, self.conn.args, stderr=self.conn.stderr.read()
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self):
|
||||||
|
"""Returns the increment of progression from
|
||||||
|
the last time this method is executed.
|
||||||
|
"""
|
||||||
|
# for cmd badblocks the increment can be negative at the
|
||||||
|
# beginning of the second step where last_percentage
|
||||||
|
# is 100 and percentage is 0. By using max we
|
||||||
|
# kind-of reset the increment and start counting for
|
||||||
|
# the second step
|
||||||
|
increment = max(self.percentage - self.last_update_percentage, 0)
|
||||||
|
self.last_update_percentage = self.percentage
|
||||||
|
return increment
|
171
ereuse_devicehub/ereuse_utils/getter.py
Normal file
171
ereuse_devicehub/ereuse_utils/getter.py
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
"""Functions to get values from dictionaries and list encoded key-value
|
||||||
|
strings with meaningful indentations.
|
||||||
|
|
||||||
|
Values obtained from these functions are sanitized and automatically
|
||||||
|
(or explicitly set) casted. Sanitization includes removing unnecessary
|
||||||
|
whitespaces and removing useless keywords (in the context of
|
||||||
|
computer hardware) from the texts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from itertools import chain
|
||||||
|
from typing import Any, Iterable, Set, Type, Union
|
||||||
|
from unittest.mock import DEFAULT
|
||||||
|
|
||||||
|
import boltons.iterutils
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from ereuse_devicehub.ereuse_utils.text import clean
|
||||||
|
|
||||||
|
|
||||||
|
def dict(
|
||||||
|
d: dict,
|
||||||
|
path: Union[str, tuple],
|
||||||
|
remove: Set[str] = set(),
|
||||||
|
default: Any = DEFAULT,
|
||||||
|
type: Type = None,
|
||||||
|
):
|
||||||
|
"""Gets a value from the dictionary and sanitizes it.
|
||||||
|
|
||||||
|
Values are patterned and compared against sets
|
||||||
|
of meaningless characters for device hardware.
|
||||||
|
|
||||||
|
:param d: A dictionary potentially containing the value.
|
||||||
|
:param path: The key or a tuple-path where the value should be.
|
||||||
|
:param remove: Remove these words if found.
|
||||||
|
:param default: A default value to return if not found. If not set,
|
||||||
|
an exception is raised.
|
||||||
|
:param type: Enforce a type on the value (like ``int``). By default
|
||||||
|
dict tries to guess the correct type.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
v = boltons.iterutils.get_path(d, (path,) if isinstance(path, str) else path)
|
||||||
|
except KeyError:
|
||||||
|
return _default(path, default)
|
||||||
|
else:
|
||||||
|
return sanitize(v, remove, type=type)
|
||||||
|
|
||||||
|
|
||||||
|
def kv(
|
||||||
|
iterable: Iterable[str],
|
||||||
|
key: str,
|
||||||
|
default: Any = DEFAULT,
|
||||||
|
sep=':',
|
||||||
|
type: Type = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Key-value. Gets a value from an iterable representing key values in the
|
||||||
|
form of a list of strings lines, for example an ``.ini`` or yaml file,
|
||||||
|
if they are opened with ``.splitlines()``.
|
||||||
|
|
||||||
|
:param iterable: An iterable of strings.
|
||||||
|
:param key: The key where the value should be.
|
||||||
|
:param default: A default value to return if not found. If not set,
|
||||||
|
an exception is raised.
|
||||||
|
:param sep: What separates the key from the value in the line.
|
||||||
|
Usually ``:`` or ``=``.
|
||||||
|
:param type: Enforce a type on the value (like ``int``). By default
|
||||||
|
dict tries to guess the correct type.
|
||||||
|
"""
|
||||||
|
for line in iterable:
|
||||||
|
try:
|
||||||
|
k, value, *_ = line.strip().split(sep)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if key == k:
|
||||||
|
return sanitize(value, type=type)
|
||||||
|
return _default(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def indents(iterable: Iterable[str], keyword: str, indent=' '):
|
||||||
|
"""For a given iterable of strings, returns blocks of the same
|
||||||
|
left indentation.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
foo1
|
||||||
|
bar1
|
||||||
|
bar2
|
||||||
|
foo2
|
||||||
|
foo2
|
||||||
|
|
||||||
|
For that text, this method would return ``[bar1, bar2]`` for passed-in
|
||||||
|
keyword ``foo1``.
|
||||||
|
|
||||||
|
:param iterable: A list of strings representing lines.
|
||||||
|
:param keyword: The title preceding the indentation.
|
||||||
|
:param indent: Which characters makes the indentation.
|
||||||
|
"""
|
||||||
|
section_pos = None
|
||||||
|
for i, line in enumerate(iterable):
|
||||||
|
if not line.startswith(indent):
|
||||||
|
if keyword in line:
|
||||||
|
section_pos = i
|
||||||
|
elif section_pos is not None:
|
||||||
|
yield iterable[section_pos:i]
|
||||||
|
section_pos = None
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _default(key, default):
|
||||||
|
if default is DEFAULT:
|
||||||
|
raise IndexError('Value {} not found.'.format(key))
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
"""Gets"""
|
||||||
|
TO_REMOVE = {'none', 'prod', 'o.e.m', 'oem', r'n/a', 'atapi', 'pc', 'unknown'}
|
||||||
|
"""Delete those *words* from the value"""
|
||||||
|
assert all(v.lower() == v for v in TO_REMOVE), 'All words need to be lower-case'
|
||||||
|
|
||||||
|
REMOVE_CHARS_BETWEEN = '(){}[]'
|
||||||
|
"""
|
||||||
|
Remove those *characters* from the value.
|
||||||
|
All chars inside those are removed. Ex: foo (bar) => foo
|
||||||
|
"""
|
||||||
|
CHARS_TO_REMOVE = '*'
|
||||||
|
"""Remove the characters.
|
||||||
|
|
||||||
|
'*' Needs to be removed or otherwise it is interpreted
|
||||||
|
as a glob expression by regexes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MEANINGLESS = {
|
||||||
|
'to be filled',
|
||||||
|
'system manufacturer',
|
||||||
|
'system product',
|
||||||
|
'sernum',
|
||||||
|
'xxxxx',
|
||||||
|
'system name',
|
||||||
|
'not specified',
|
||||||
|
'modulepartnumber',
|
||||||
|
'system serial',
|
||||||
|
'0001-067a-0000',
|
||||||
|
'partnum',
|
||||||
|
'manufacturer',
|
||||||
|
'0000000',
|
||||||
|
'fffff',
|
||||||
|
'jedec id:ad 00 00 00 00 00 00 00',
|
||||||
|
'012000',
|
||||||
|
'x.x',
|
||||||
|
'sku',
|
||||||
|
}
|
||||||
|
"""Discard a value if any of these values are inside it. """
|
||||||
|
assert all(v.lower() == v for v in MEANINGLESS), 'All values need to be lower-case'
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(value, remove=set(), type=None):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
remove = remove | TO_REMOVE
|
||||||
|
regex = r'({})\W'.format('|'.join(s for s in remove))
|
||||||
|
val = re.sub(regex, '', value, flags=re.IGNORECASE)
|
||||||
|
val = '' if val.lower() in remove else val # regex's `\W` != whole string
|
||||||
|
val = re.sub(r'\([^)]*\)', '', val) # Remove everything between
|
||||||
|
for char_to_remove in chain(REMOVE_CHARS_BETWEEN, CHARS_TO_REMOVE):
|
||||||
|
val = val.replace(char_to_remove, '')
|
||||||
|
val = clean(val)
|
||||||
|
if val and not any(meaningless in val.lower() for meaningless in MEANINGLESS):
|
||||||
|
return type(val) if type else yaml.load(val, Loader=yaml.SafeLoader)
|
||||||
|
else:
|
||||||
|
return None
|
143
ereuse_devicehub/ereuse_utils/naming.py
Normal file
143
ereuse_devicehub/ereuse_utils/naming.py
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
from inflection import (
|
||||||
|
camelize,
|
||||||
|
dasherize,
|
||||||
|
parameterize,
|
||||||
|
pluralize,
|
||||||
|
singularize,
|
||||||
|
underscore,
|
||||||
|
)
|
||||||
|
|
||||||
|
HID_CONVERSION_DOC = """
|
||||||
|
The HID is the result of concatenating,
|
||||||
|
in the following order: the type of device (ex. Computer),
|
||||||
|
the manufacturer name, the model name, and the S/N. It is joined
|
||||||
|
with hyphens, and adapted to comply with the URI specification, so
|
||||||
|
it can be used in the URI identifying the device on the Internet.
|
||||||
|
The conversion is done as follows:
|
||||||
|
|
||||||
|
1. non-ASCII characters are converted to their ASCII equivalent or
|
||||||
|
removed.
|
||||||
|
2. Characterst that are not letters or numbers are converted to
|
||||||
|
underscores, in a way that there are no trailing underscores
|
||||||
|
and no underscores together, and they are set to lowercase.
|
||||||
|
|
||||||
|
Ex. ``laptop-acer-aod270-lusga_0d0242201212c7614``
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Naming:
|
||||||
|
"""
|
||||||
|
In DeviceHub there are many ways to name the same resource (yay!), this is because of all the different
|
||||||
|
types of schemas we work with. But no worries, we offer easy ways to change between naming conventions.
|
||||||
|
|
||||||
|
- TypeCase (or resource-type) is the one represented with '@type' and follow PascalCase and always singular.
|
||||||
|
This is the standard preferred one.
|
||||||
|
- resource-case is the eve naming, using the standard URI conventions. This one is tricky, as although the types
|
||||||
|
are represented in singular, the URI convention is to be plural (Event vs events), however just few of them
|
||||||
|
follow this rule (Snapshot [type] to snapshot [resource]). You can set which ones you want to change their
|
||||||
|
number.
|
||||||
|
- python_case is the one used by python for its folders and modules. It is underscored and always singular.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TYPE_PREFIX = ':'
|
||||||
|
RESOURCE_PREFIX = '_'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resource(string: str):
|
||||||
|
"""
|
||||||
|
:param string: String can be type, resource or python case
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prefix, resulting_type = Naming.pop_prefix(string)
|
||||||
|
prefix += Naming.RESOURCE_PREFIX
|
||||||
|
except IndexError:
|
||||||
|
prefix = ''
|
||||||
|
resulting_type = string
|
||||||
|
resulting_type = dasherize(underscore(resulting_type))
|
||||||
|
return prefix + pluralize(resulting_type)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def python(string: str):
|
||||||
|
"""
|
||||||
|
:param string: String can be type, resource or python case
|
||||||
|
"""
|
||||||
|
return underscore(singularize(string))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def type(string: str):
|
||||||
|
try:
|
||||||
|
prefix, resulting_type = Naming.pop_prefix(string)
|
||||||
|
prefix += Naming.TYPE_PREFIX
|
||||||
|
except IndexError:
|
||||||
|
prefix = ''
|
||||||
|
resulting_type = string
|
||||||
|
resulting_type = singularize(resulting_type)
|
||||||
|
resulting_type = resulting_type.replace(
|
||||||
|
'-', '_'
|
||||||
|
) # camelize does not convert '-' but '_'
|
||||||
|
return prefix + camelize(resulting_type)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def url_word(word: str):
|
||||||
|
"""
|
||||||
|
Normalizes a full word to be inserted to an url. If the word has spaces, etc, is used '_' and not '-'
|
||||||
|
"""
|
||||||
|
return parameterize(word, '_')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def pop_prefix(string: str):
|
||||||
|
"""Erases the prefix and returns it.
|
||||||
|
:throws IndexError: There is no prefix.
|
||||||
|
:return A set with two elements: 1- the prefix, 2- the type without it.
|
||||||
|
"""
|
||||||
|
result = string.split(Naming.TYPE_PREFIX)
|
||||||
|
if len(result) == 1:
|
||||||
|
result = string.split(Naming.RESOURCE_PREFIX)
|
||||||
|
if len(result) == 1:
|
||||||
|
raise IndexError()
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new_type(type_name: str, prefix: str or None = None) -> str:
|
||||||
|
"""
|
||||||
|
Creates a resource type with optionally a prefix.
|
||||||
|
|
||||||
|
Using the rules of JSON-LD, we use prefixes to disambiguate between different types with the same name:
|
||||||
|
one can Accept a device or a project. In eReuse.org there are different events with the same names, in
|
||||||
|
linked-data terms they have different URI. In eReuse.org, we solve this with the following:
|
||||||
|
|
||||||
|
"@type": "devices:Accept" // the URI for these events is 'devices/events/accept'
|
||||||
|
"@type": "projects:Accept" // the URI for these events is 'projects/events/accept
|
||||||
|
...
|
||||||
|
|
||||||
|
Type is only used in events, when there are ambiguities. The rest of
|
||||||
|
|
||||||
|
"@type": "devices:Accept"
|
||||||
|
"@type": "Accept"
|
||||||
|
|
||||||
|
But these not:
|
||||||
|
|
||||||
|
"@type": "projects:Accept" // it is an event from a project
|
||||||
|
"@type": "Accept" // it is an event from a device
|
||||||
|
"""
|
||||||
|
if Naming.TYPE_PREFIX in type_name:
|
||||||
|
raise TypeError(
|
||||||
|
'Cannot create new type: type {} is already prefixed.'.format(type_name)
|
||||||
|
)
|
||||||
|
prefix = (prefix + Naming.TYPE_PREFIX) if prefix is not None else ''
|
||||||
|
return prefix + type_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hid(type: str, manufacturer: str, model: str, serial_number: str) -> str:
|
||||||
|
(
|
||||||
|
"""Computes the HID for the given properties of a device.
|
||||||
|
The HID is suitable to use to an URI.
|
||||||
|
"""
|
||||||
|
+ HID_CONVERSION_DOC
|
||||||
|
)
|
||||||
|
return '{type}-{mn}-{ml}-{sn}'.format(
|
||||||
|
type=Naming.url_word(type),
|
||||||
|
mn=Naming.url_word(manufacturer),
|
||||||
|
ml=Naming.url_word(model),
|
||||||
|
sn=Naming.url_word(serial_number),
|
||||||
|
)
|
85
ereuse_devicehub/ereuse_utils/nested_lookup.py
Normal file
85
ereuse_devicehub/ereuse_utils/nested_lookup.py
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
class NestedLookup:
|
||||||
|
@staticmethod
|
||||||
|
def __new__(cls, document, references, operation):
|
||||||
|
"""Lookup a key in a nested document, return a list of values
|
||||||
|
From https://github.com/russellballestrini/nested-lookup/ but in python 3
|
||||||
|
"""
|
||||||
|
return list(NestedLookup._nested_lookup(document, references, operation))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def key_equality_factory(key_to_find):
|
||||||
|
def key_equality(key, _):
|
||||||
|
return key == key_to_find
|
||||||
|
|
||||||
|
return key_equality
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_sub_type_factory(type):
|
||||||
|
def _is_sub_type(_, value):
|
||||||
|
return is_sub_type(value, type)
|
||||||
|
|
||||||
|
return _is_sub_type
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def key_value_equality_factory(key_to_find, value_to_find):
|
||||||
|
def key_value_equality(key, value):
|
||||||
|
return key == key_to_find and value == value_to_find
|
||||||
|
|
||||||
|
return key_value_equality
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def key_value_containing_value_factory(key_to_find, value_to_find):
|
||||||
|
def key_value_containing_value(key, value):
|
||||||
|
return key == key_to_find and value_to_find in value
|
||||||
|
|
||||||
|
return key_value_containing_value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _nested_lookup(document, references, operation): # noqa: C901
|
||||||
|
"""Lookup a key in a nested document, yield a value"""
|
||||||
|
if isinstance(document, list):
|
||||||
|
for d in document:
|
||||||
|
for result in NestedLookup._nested_lookup(d, references, operation):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
if isinstance(document, dict):
|
||||||
|
for k, v in document.items():
|
||||||
|
if operation(k, v):
|
||||||
|
references.append((document, k))
|
||||||
|
yield v
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
for result in NestedLookup._nested_lookup(v, references, operation):
|
||||||
|
yield result
|
||||||
|
elif isinstance(v, list):
|
||||||
|
for d in v:
|
||||||
|
for result in NestedLookup._nested_lookup(
|
||||||
|
d, references, operation
|
||||||
|
):
|
||||||
|
yield result
|
||||||
|
|
||||||
|
|
||||||
|
def is_sub_type(value, resource_type):
|
||||||
|
try:
|
||||||
|
return issubclass(value, resource_type)
|
||||||
|
except TypeError:
|
||||||
|
return issubclass(value.__class__, resource_type)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nested_dicts_with_key_value(parent_dict: dict, key, value):
|
||||||
|
"""Return all nested dictionaries that contain a key with a specific value. A sub-case of NestedLookup."""
|
||||||
|
references = []
|
||||||
|
NestedLookup(
|
||||||
|
parent_dict, references, NestedLookup.key_value_equality_factory(key, value)
|
||||||
|
)
|
||||||
|
return (document for document, _ in references)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nested_dicts_with_key_containing_value(parent_dict: dict, key, value):
|
||||||
|
"""Return all nested dictionaries that contain a key with a specific value. A sub-case of NestedLookup."""
|
||||||
|
references = []
|
||||||
|
NestedLookup(
|
||||||
|
parent_dict,
|
||||||
|
references,
|
||||||
|
NestedLookup.key_value_containing_value_factory(key, value),
|
||||||
|
)
|
||||||
|
return (document for document, _ in references)
|
285
ereuse_devicehub/ereuse_utils/session.py
Normal file
285
ereuse_devicehub/ereuse_utils/session.py
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, Iterable, Tuple, TypeVar, Union
|
||||||
|
|
||||||
|
import boltons.urlutils
|
||||||
|
from requests import Response
|
||||||
|
from requests_toolbelt.sessions import BaseUrlSession
|
||||||
|
from urllib3 import Retry
|
||||||
|
|
||||||
|
from ereuse_devicehub import ereuse_utils
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
Query = Iterable[Tuple[str, Any]]
|
||||||
|
|
||||||
|
Status = Union[int]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from typing import Protocol # Only py 3.6+
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
class HasStatusProperty(Protocol):
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
self.status = ... # type: int
|
||||||
|
|
||||||
|
|
||||||
|
Status = Union[int, HasStatusProperty]
|
||||||
|
|
||||||
|
JSON = 'application/json'
|
||||||
|
ANY = '*/*'
|
||||||
|
AUTH = 'Authorization'
|
||||||
|
BASIC = 'Basic {}'
|
||||||
|
URL = Union[str, boltons.urlutils.URL]
|
||||||
|
Data = Union[str, dict, ereuse_utils.Dumpeable]
|
||||||
|
Res = Tuple[Union[Dict[str, Any], str], Response]
|
||||||
|
|
||||||
|
|
||||||
|
# actual code
|
||||||
|
|
||||||
|
|
||||||
|
class Session(BaseUrlSession):
|
||||||
|
"""A BaseUrlSession that always raises for status and sets a
|
||||||
|
timeout for all requests by default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url=None, timeout=15):
|
||||||
|
"""
|
||||||
|
:param base_url:
|
||||||
|
:param timeout: Time requests will wait to receive the first
|
||||||
|
response bytes (not the whole) from the server. In seconds.
|
||||||
|
"""
|
||||||
|
super().__init__(base_url)
|
||||||
|
self.timeout = timeout
|
||||||
|
self.hooks['response'] = lambda r, *args, **kwargs: r.raise_for_status()
|
||||||
|
|
||||||
|
def request(self, method, url, *args, **kwargs):
|
||||||
|
kwargs.setdefault('timeout', self.timeout)
|
||||||
|
return super().request(method, url, *args, **kwargs)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{} base={}>.'.format(self.__class__.__name__, self.base_url)
|
||||||
|
|
||||||
|
|
||||||
|
class DevicehubClient(Session):
|
||||||
|
"""A Session pre-configured to connect to Devicehub-like APIs."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: URL = None,
|
||||||
|
token: str = None,
|
||||||
|
inventory: Union[str, bool] = False,
|
||||||
|
**kwargs):
|
||||||
|
"""Initializes a session pointing to a Devicehub endpoint.
|
||||||
|
|
||||||
|
Authentication can be passed-in as a token for endpoints
|
||||||
|
that require them, now at ini, after when executing the method,
|
||||||
|
or in between with ``set_auth``.
|
||||||
|
|
||||||
|
:param base_url: An url pointing to a endpoint.
|
||||||
|
:param token: A Base64 encoded token, as given by a devicehub.
|
||||||
|
You can encode tokens by executing `encode_token`.
|
||||||
|
:param inventory: If True, use the default inventory of the user.
|
||||||
|
If False, do not use inventories (single-inventory
|
||||||
|
database, this is the option by default).
|
||||||
|
If a string, always use the set inventory.
|
||||||
|
"""
|
||||||
|
if isinstance(base_url, boltons.urlutils.URL):
|
||||||
|
base_url = base_url.to_text()
|
||||||
|
else:
|
||||||
|
base_url = str(base_url)
|
||||||
|
super().__init__(base_url, **kwargs)
|
||||||
|
assert base_url[-1] != '/', 'Do not provide a final slash to the URL'
|
||||||
|
if token:
|
||||||
|
self.set_auth(token)
|
||||||
|
self.inventory = inventory
|
||||||
|
self.user = None # type: Dict[str, object]
|
||||||
|
|
||||||
|
def set_auth(self, token):
|
||||||
|
self.headers['Authorization'] = 'Basic {}'.format(token)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def encode_token(cls, token: str):
|
||||||
|
"""Encodes a token suitable for a Devicehub endpoint."""
|
||||||
|
return base64.b64encode(str.encode(str(token) + ':')).decode()
|
||||||
|
|
||||||
|
def login(self, email: str, password: str) -> Dict[str, Any]:
|
||||||
|
"""Performs login, authenticating future requests.
|
||||||
|
|
||||||
|
:return: The logged-in user.
|
||||||
|
"""
|
||||||
|
user, _ = self.post('/users/login/', {'email': email, 'password': password}, status=200)
|
||||||
|
self.set_auth(user['token'])
|
||||||
|
self.user = user
|
||||||
|
self.inventory = user['inventories'][0]['id']
|
||||||
|
return user
|
||||||
|
|
||||||
|
def get(self,
|
||||||
|
base_url: URL,
|
||||||
|
uri=None,
|
||||||
|
status: Status = 200,
|
||||||
|
query: Query = tuple(),
|
||||||
|
accept=JSON,
|
||||||
|
content_type=JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
token=None,
|
||||||
|
**kwargs) -> Res:
|
||||||
|
return super().get(base_url,
|
||||||
|
uri=uri,
|
||||||
|
status=status,
|
||||||
|
query=query,
|
||||||
|
accept=accept,
|
||||||
|
content_type=content_type,
|
||||||
|
headers=headers,
|
||||||
|
token=token, **kwargs)
|
||||||
|
|
||||||
|
def post(self, base_url: URL,
|
||||||
|
data: Data,
|
||||||
|
uri=None,
|
||||||
|
status: Status = 201,
|
||||||
|
query: Query = tuple(),
|
||||||
|
accept=JSON,
|
||||||
|
content_type=JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
token=None,
|
||||||
|
**kwargs) -> Res:
|
||||||
|
return super().post(base_url,
|
||||||
|
data=data,
|
||||||
|
uri=uri,
|
||||||
|
status=status,
|
||||||
|
query=query,
|
||||||
|
accept=accept,
|
||||||
|
content_type=content_type,
|
||||||
|
headers=headers,
|
||||||
|
token=token, **kwargs)
|
||||||
|
|
||||||
|
def delete(self,
|
||||||
|
base_url: URL,
|
||||||
|
uri=None,
|
||||||
|
status: Status = 204,
|
||||||
|
query: Query = tuple(),
|
||||||
|
accept=JSON,
|
||||||
|
content_type=JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
token=None,
|
||||||
|
**kwargs) -> Res:
|
||||||
|
return super().delete(base_url,
|
||||||
|
uri=uri,
|
||||||
|
status=status,
|
||||||
|
query=query,
|
||||||
|
accept=accept,
|
||||||
|
content_type=content_type,
|
||||||
|
headers=headers,
|
||||||
|
token=token, **kwargs)
|
||||||
|
|
||||||
|
def patch(self, base_url: URL,
|
||||||
|
data: Data,
|
||||||
|
uri=None,
|
||||||
|
status: Status = 201,
|
||||||
|
query: Query = tuple(),
|
||||||
|
accept=JSON,
|
||||||
|
content_type=JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
token=None,
|
||||||
|
**kwargs) -> Res:
|
||||||
|
return super().patch(base_url,
|
||||||
|
data=data,
|
||||||
|
uri=uri,
|
||||||
|
status=status,
|
||||||
|
query=query,
|
||||||
|
accept=accept,
|
||||||
|
content_type=content_type,
|
||||||
|
headers=headers,
|
||||||
|
token=token, **kwargs)
|
||||||
|
|
||||||
|
def request(self,
|
||||||
|
method,
|
||||||
|
base_url: URL,
|
||||||
|
uri=None,
|
||||||
|
status: Status = 200,
|
||||||
|
query: Query = tuple(),
|
||||||
|
accept=JSON,
|
||||||
|
content_type=JSON,
|
||||||
|
data=None,
|
||||||
|
headers: dict = None,
|
||||||
|
token=None,
|
||||||
|
**kw) -> Res:
|
||||||
|
assert not kw.get('json', None), 'Do not use json; use data.'
|
||||||
|
# We allow uris without slashes for item endpoints
|
||||||
|
uri = str(uri) if uri else None
|
||||||
|
headers = headers or {}
|
||||||
|
headers['Accept'] = accept
|
||||||
|
headers['Content-Type'] = content_type
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = 'Basic {}'.format(token)
|
||||||
|
if data and content_type == JSON:
|
||||||
|
data = json.dumps(data, cls=ereuse_utils.JSONEncoder, sort_keys=True)
|
||||||
|
url = base_url if not isinstance(base_url, boltons.urlutils.URL) else base_url.to_text()
|
||||||
|
assert url[-1] == '/', 'base_url should end with a slash'
|
||||||
|
if self.inventory and not isinstance(self.inventory, bool):
|
||||||
|
url = '{}/{}'.format(self.inventory, base_url)
|
||||||
|
assert url[-1] == '/', 'base_url should end with a slash'
|
||||||
|
if uri:
|
||||||
|
url = self.parse_uri(url, uri)
|
||||||
|
if query:
|
||||||
|
url = self.parse_query(url, query)
|
||||||
|
response = super().request(method, url, data=data, headers=headers, **kw)
|
||||||
|
if status:
|
||||||
|
_status = getattr(status, 'code', status)
|
||||||
|
if _status != response.status_code:
|
||||||
|
raise WrongStatus('Req to {} failed bc the status is {} but it should have been {}'
|
||||||
|
.format(url, response.status_code, _status))
|
||||||
|
data = response.content if not accept == JSON or not response.content else response.json()
|
||||||
|
return data, response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_uri(base_url, uri):
|
||||||
|
return boltons.urlutils.URL(base_url).navigate(uri).to_text()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_query(uri, query):
|
||||||
|
url = boltons.urlutils.URL(uri)
|
||||||
|
url.query_params = boltons.urlutils.QueryParamDict([
|
||||||
|
(k, json.dumps(v, cls=ereuse_utils.JSONEncoder) if isinstance(v, (list, dict)) else v)
|
||||||
|
for k, v in query
|
||||||
|
])
|
||||||
|
return url.to_text()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{} base={} inv={} user={}>.'.format(self.__class__.__name__, self.base_url,
|
||||||
|
self.inventory, self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class WrongStatus(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
|
||||||
|
T = TypeVar('T', bound=requests.Session)
|
||||||
|
|
||||||
|
|
||||||
|
def retry(session: T,
|
||||||
|
retries=3,
|
||||||
|
backoff_factor=1,
|
||||||
|
status_to_retry=(500, 502, 504)) -> T:
|
||||||
|
"""Configures requests from the given session to retry in
|
||||||
|
failed requests due to connection errors, HTTP response codes
|
||||||
|
with ``status_to_retry`` and 30X redirections.
|
||||||
|
|
||||||
|
Remember that you still need
|
||||||
|
"""
|
||||||
|
# From https://www.peterbe.com/plog/best-practice-with-retries-with-requests
|
||||||
|
# Doc in https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html#module-urllib3.util.retry
|
||||||
|
session = session or requests.Session()
|
||||||
|
retry = Retry(
|
||||||
|
total=retries,
|
||||||
|
read=retries,
|
||||||
|
connect=retries,
|
||||||
|
backoff_factor=backoff_factor,
|
||||||
|
status_forcelist=status_to_retry,
|
||||||
|
method_whitelist=False # Retry too in non-idempotent methods like POST
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retry)
|
||||||
|
session.mount('http://', adapter)
|
||||||
|
session.mount('https://', adapter)
|
||||||
|
return session
|
165
ereuse_devicehub/ereuse_utils/test.py
Normal file
165
ereuse_devicehub/ereuse_utils/test.py
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Dict, Tuple, Union
|
||||||
|
|
||||||
|
from flask import json
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
from ereuse_devicehub.ereuse_utils.session import ANY, AUTH, BASIC, DevicehubClient, JSON, Query, Status
|
||||||
|
|
||||||
|
ANY = ANY
|
||||||
|
AUTH = AUTH
|
||||||
|
BASIC = BASIC
|
||||||
|
|
||||||
|
Res = Tuple[Union[Dict[str, object], str], Response]
|
||||||
|
|
||||||
|
|
||||||
|
class Client(FlaskClient):
|
||||||
|
"""
|
||||||
|
A client for the REST servers of DeviceHub and WorkbenchServer.
|
||||||
|
|
||||||
|
- JSON first. By default it sends and expects receiving JSON files.
|
||||||
|
- Assert regular status responses, like 200 for GET.
|
||||||
|
- Auto-parses a nested dictionary of URL query params to the
|
||||||
|
URL version with nested properties to JSON.
|
||||||
|
- Meaningful headers format: a dictionary of name-values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def open(self,
|
||||||
|
uri: str,
|
||||||
|
status: Status = 200,
|
||||||
|
query: Query = tuple(),
|
||||||
|
accept=JSON,
|
||||||
|
content_type=JSON,
|
||||||
|
item=None,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw) -> Res:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param uri: The URI without basename and query.
|
||||||
|
:param status: Assert the response for specified status. Set
|
||||||
|
None to avoid.
|
||||||
|
:param query: The query of the URL in the form of
|
||||||
|
[(key1, value1), (key2, value2), (key1, value3)].
|
||||||
|
If value is a list or a dict, they will be
|
||||||
|
converted to JSON.
|
||||||
|
Please, see :class:`boltons.urlutils`.
|
||||||
|
QueryParamDict` for more info.
|
||||||
|
:param accept: The Accept header. If 'application/json'
|
||||||
|
(default) then it will parse incoming JSON.
|
||||||
|
:param item: The last part of the path. Useful to do something
|
||||||
|
like ``get('db/accounts', item='24')``. If you
|
||||||
|
use ``item``, you can't set a final backslash into
|
||||||
|
``uri`` (or the parse will fail).
|
||||||
|
:param headers: A dictionary of headers, where keys are header
|
||||||
|
names and values their values.
|
||||||
|
Ex: {'Accept', 'application/json'}.
|
||||||
|
:param kw: Kwargs passed into parent ``open``.
|
||||||
|
:return: A tuple with: 1. response data, as a string or JSON
|
||||||
|
depending of Accept, and 2. the Response object.
|
||||||
|
"""
|
||||||
|
j_encoder = self.application.json_encoder
|
||||||
|
headers = headers or {}
|
||||||
|
headers['Accept'] = accept
|
||||||
|
headers['Content-Type'] = content_type
|
||||||
|
headers = [(k, v) for k, v in headers.items()]
|
||||||
|
if 'data' in kw and content_type == JSON:
|
||||||
|
kw['data'] = json.dumps(kw['data'], cls=j_encoder)
|
||||||
|
if item:
|
||||||
|
uri = DevicehubClient.parse_uri(uri, item)
|
||||||
|
if query:
|
||||||
|
uri = DevicehubClient.parse_query(uri, query)
|
||||||
|
response = super().open(uri, headers=headers, **kw)
|
||||||
|
if status:
|
||||||
|
_status = getattr(status, 'code', status)
|
||||||
|
assert response.status_code == _status, \
|
||||||
|
'Expected status code {} but got {}. Returned data is:\n' \
|
||||||
|
'{}'.format(_status, response.status_code, response.get_data().decode())
|
||||||
|
|
||||||
|
data = response.get_data()
|
||||||
|
with suppress(UnicodeDecodeError):
|
||||||
|
data = data.decode()
|
||||||
|
if accept == JSON:
|
||||||
|
data = json.loads(data) if data else {}
|
||||||
|
return data, response
|
||||||
|
|
||||||
|
def get(self,
|
||||||
|
uri: str,
|
||||||
|
query: Query = tuple(),
|
||||||
|
item: str = None,
|
||||||
|
status: Status = 200,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw) -> Res:
|
||||||
|
"""
|
||||||
|
Performs a GET.
|
||||||
|
|
||||||
|
See the parameters in :meth:`ereuse_utils.test.Client.open`.
|
||||||
|
Moreover:
|
||||||
|
|
||||||
|
:param query: A dictionary of query params. If a parameter is a
|
||||||
|
dict or a list, it will be parsed to JSON, then
|
||||||
|
all params are encoded with ``urlencode``.
|
||||||
|
:param kw: Kwargs passed into parent ``open``.
|
||||||
|
"""
|
||||||
|
return super().get(uri, item=item, status=status, accept=accept, headers=headers,
|
||||||
|
query=query, **kw)
|
||||||
|
|
||||||
|
def post(self,
|
||||||
|
uri: str,
|
||||||
|
data: str or dict,
|
||||||
|
query: Query = tuple(),
|
||||||
|
status: Status = 201,
|
||||||
|
content_type: str = JSON,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw) -> Res:
|
||||||
|
"""
|
||||||
|
Performs a POST.
|
||||||
|
|
||||||
|
See the parameters in :meth:`ereuse_utils.test.Client.open`.
|
||||||
|
"""
|
||||||
|
return super().post(uri, data=data, status=status, content_type=content_type,
|
||||||
|
accept=accept, headers=headers, query=query, **kw)
|
||||||
|
|
||||||
|
def patch(self,
|
||||||
|
uri: str,
|
||||||
|
data: str or dict,
|
||||||
|
query: Query = tuple(),
|
||||||
|
status: Status = 200,
|
||||||
|
content_type: str = JSON,
|
||||||
|
item: str = None,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw) -> Res:
|
||||||
|
"""
|
||||||
|
Performs a PATCH.
|
||||||
|
|
||||||
|
See the parameters in :meth:`ereuse_utils.test.Client.open`.
|
||||||
|
"""
|
||||||
|
return super().patch(uri, item=item, data=data, status=status, content_type=content_type,
|
||||||
|
accept=accept, headers=headers, query=query, **kw)
|
||||||
|
|
||||||
|
def put(self,
|
||||||
|
uri: str,
|
||||||
|
data: str or dict,
|
||||||
|
query: Query = tuple(),
|
||||||
|
status: Status = 201,
|
||||||
|
content_type: str = JSON,
|
||||||
|
item: str = None,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw) -> Res:
|
||||||
|
return super().put(uri, item=item, data=data, status=status, content_type=content_type,
|
||||||
|
accept=accept, headers=headers, query=query, **kw)
|
||||||
|
|
||||||
|
def delete(self,
|
||||||
|
uri: str,
|
||||||
|
query: Query = tuple(),
|
||||||
|
item: str = None,
|
||||||
|
status: Status = 204,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw) -> Res:
|
||||||
|
return super().delete(uri, query=query, item=item, status=status, accept=accept,
|
||||||
|
headers=headers, **kw)
|
72
ereuse_devicehub/ereuse_utils/text.py
Normal file
72
ereuse_devicehub/ereuse_utils/text.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
from typing import Iterator, Set, Union
|
||||||
|
|
||||||
|
|
||||||
|
def grep(text: str, value: str):
|
||||||
|
"""An easy 'grep -i' that yields lines where value is found."""
|
||||||
|
for line in text.splitlines():
|
||||||
|
if value in line:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
|
||||||
|
def between(text: str, begin='(', end=')'):
|
||||||
|
"""Dead easy text between two characters.
|
||||||
|
Not recursive or repetitions.
|
||||||
|
"""
|
||||||
|
return text.split(begin)[-1].split(end)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def numbers(text: str) -> Iterator[Union[int, float]]:
|
||||||
|
"""Gets numbers in strings with other characters.
|
||||||
|
|
||||||
|
Integer Numbers: 1 2 3 987 +4 -8
|
||||||
|
Decimal Numbers: 0.1 2. .3 .987 +4.0 -0.8
|
||||||
|
Scientific Notation: 1e2 0.2e2 3.e2 .987e2 +4e-1 -8.e+2
|
||||||
|
Numbers with percentages: 49% 32.39%
|
||||||
|
|
||||||
|
This returns int or float.
|
||||||
|
"""
|
||||||
|
# From https://regexr.com/33jqd
|
||||||
|
for x in re.finditer(r'[+-]?(?=\.\d|\d)(?:\d+)?(?:\.?\d*)(?:[eE][+-]?\d+)?', text):
|
||||||
|
yield ast.literal_eval(x.group())
|
||||||
|
|
||||||
|
|
||||||
|
def positive_percentages(
|
||||||
|
text: str, lengths: Set[int] = None, decimal_numbers: int = None
|
||||||
|
) -> Iterator[Union[int, float]]:
|
||||||
|
"""Gets numbers postfixed with a '%' in strings with other characters.
|
||||||
|
|
||||||
|
1)100% 2)56.78% 3)56 78.90% 4)34.6789% some text
|
||||||
|
|
||||||
|
:param text: The text to search for.
|
||||||
|
:param lengths: A set of lengths that the percentage
|
||||||
|
number should have to be considered valid.
|
||||||
|
Ex. {5,6} would validate '90.32' and '100.00'
|
||||||
|
"""
|
||||||
|
# From https://regexr.com/3aumh
|
||||||
|
for x in re.finditer(r'[\d|\.]+%', text):
|
||||||
|
num = x.group()[:-1]
|
||||||
|
if lengths:
|
||||||
|
if not len(num) in lengths:
|
||||||
|
continue
|
||||||
|
if decimal_numbers:
|
||||||
|
try:
|
||||||
|
pos = num.rindex('.')
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if len(num) - pos - 1 != decimal_numbers:
|
||||||
|
continue
|
||||||
|
yield float(num)
|
||||||
|
|
||||||
|
|
||||||
|
def macs(text: str) -> Iterator[str]:
|
||||||
|
"""Find MACs in strings with other characters."""
|
||||||
|
for x in re.finditer('{0}:{0}:{0}:{0}:{0}:{0}'.format(r'[a-fA-F0-9.+_-]+'), text):
|
||||||
|
yield x.group()
|
||||||
|
|
||||||
|
|
||||||
|
def clean(text: str) -> str:
|
||||||
|
"""Trims the text and replaces multiple spaces with a single space."""
|
||||||
|
return ' '.join(text.split())
|
80
ereuse_devicehub/ereuse_utils/usb_flash_drive.py
Normal file
80
ereuse_devicehub/ereuse_utils/usb_flash_drive.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import usb.core
|
||||||
|
import usb.util
|
||||||
|
from usb import CLASS_MASS_STORAGE
|
||||||
|
|
||||||
|
from ereuse_devicehub.ereuse_utils.naming import Naming
|
||||||
|
|
||||||
|
|
||||||
|
def plugged_usbs(multiple=True) -> map or dict: # noqa: C901
|
||||||
|
"""
|
||||||
|
Gets the plugged-in USB Flash drives (pen-drives).
|
||||||
|
|
||||||
|
If multiple is true, it returns a map, and a dict otherwise.
|
||||||
|
|
||||||
|
If multiple is false, this method will raise a :class:`.NoUSBFound` if no USB is found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class FindPenDrives(object):
|
||||||
|
# From https://github.com/pyusb/pyusb/blob/master/docs/tutorial.rst
|
||||||
|
def __init__(self, class_):
|
||||||
|
self._class = class_
|
||||||
|
|
||||||
|
def __call__(self, device):
|
||||||
|
# first, let's check the device
|
||||||
|
if device.bDeviceClass == self._class:
|
||||||
|
return True
|
||||||
|
# ok, transverse all devices to find an
|
||||||
|
# interface that matches our class
|
||||||
|
for cfg in device:
|
||||||
|
# find_descriptor: what's it?
|
||||||
|
intf = usb.util.find_descriptor(cfg, bInterfaceClass=self._class)
|
||||||
|
# We don't want Card readers
|
||||||
|
if intf is not None:
|
||||||
|
try:
|
||||||
|
product = intf.device.product.lower()
|
||||||
|
except ValueError as e:
|
||||||
|
if 'langid' in str(e):
|
||||||
|
raise OSError(
|
||||||
|
'Cannot get "langid". Do you have permissions?'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
if 'crw' not in product and 'reader' not in product:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_pendrive(pen: usb.Device) -> dict:
|
||||||
|
if not pen.manufacturer or not pen.product or not pen.serial_number:
|
||||||
|
raise UsbDoesNotHaveHid()
|
||||||
|
manufacturer = pen.manufacturer.strip() or str(pen.idVendor)
|
||||||
|
model = pen.product.strip() or str(pen.idProduct)
|
||||||
|
serial_number = pen.serial_number.strip()
|
||||||
|
hid = Naming.hid('USBFlashDrive', manufacturer, model, serial_number)
|
||||||
|
return {
|
||||||
|
'id': hid, # Make live easier to DeviceHubClient by using _id
|
||||||
|
'hid': hid,
|
||||||
|
'type': 'USBFlashDrive',
|
||||||
|
'serialNumber': serial_number,
|
||||||
|
'model': model,
|
||||||
|
'manufacturer': manufacturer,
|
||||||
|
'vendorId': pen.idVendor,
|
||||||
|
'productId': pen.idProduct,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = usb.core.find(
|
||||||
|
find_all=multiple, custom_match=FindPenDrives(CLASS_MASS_STORAGE)
|
||||||
|
)
|
||||||
|
if multiple:
|
||||||
|
return map(get_pendrive, result)
|
||||||
|
else:
|
||||||
|
if not result:
|
||||||
|
raise NoUSBFound()
|
||||||
|
return get_pendrive(result)
|
||||||
|
|
||||||
|
|
||||||
|
class NoUSBFound(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UsbDoesNotHaveHid(Exception):
|
||||||
|
pass
|
|
@ -32,6 +32,7 @@ from wtforms.fields import FormField
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.inventory.models import (
|
from ereuse_devicehub.inventory.models import (
|
||||||
DeliveryNote,
|
DeliveryNote,
|
||||||
|
DeviceDocument,
|
||||||
ReceiverNote,
|
ReceiverNote,
|
||||||
Transfer,
|
Transfer,
|
||||||
TransferCustomerDetails,
|
TransferCustomerDetails,
|
||||||
|
@ -69,7 +70,7 @@ from ereuse_devicehub.resources.device.models import (
|
||||||
from ereuse_devicehub.resources.documents.models import DataWipeDocument
|
from ereuse_devicehub.resources.documents.models import DataWipeDocument
|
||||||
from ereuse_devicehub.resources.enums import Severity
|
from ereuse_devicehub.resources.enums import Severity
|
||||||
from ereuse_devicehub.resources.hash_reports import insert_hash
|
from ereuse_devicehub.resources.hash_reports import insert_hash
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot, ShareLot
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
@ -110,6 +111,15 @@ DEVICES = {
|
||||||
"Other Devices": ["Other"],
|
"Other Devices": ["Other"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TYPES_DOCUMENTS = [
|
||||||
|
("", ""),
|
||||||
|
("image", "Image"),
|
||||||
|
("main_image", "Main Image"),
|
||||||
|
("functionality_report", "Functionality Report"),
|
||||||
|
("data_sanitization_report", "Data Sanitization Report"),
|
||||||
|
("disposition_report", "Disposition Report"),
|
||||||
|
]
|
||||||
|
|
||||||
COMPUTERS = ['Desktop', 'Laptop', 'Server', 'Computer']
|
COMPUTERS = ['Desktop', 'Laptop', 'Server', 'Computer']
|
||||||
|
|
||||||
MONITORS = ["ComputerMonitor", "Monitor", "TelevisionSet", "Projector"]
|
MONITORS = ["ComputerMonitor", "Monitor", "TelevisionSet", "Projector"]
|
||||||
|
@ -150,11 +160,14 @@ class FilterForm(FlaskForm):
|
||||||
'', choices=DEVICES, default="All Computers", render_kw={'class': "form-select"}
|
'', choices=DEVICES, default="All Computers", render_kw={'class': "form-select"}
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, lots, lot_id, *args, **kwargs):
|
def __init__(self, lots, lot, lot_id, *args, **kwargs):
|
||||||
self.all_devices = kwargs.pop('all_devices', False)
|
self.all_devices = kwargs.pop('all_devices', False)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.lots = lots
|
self.lots = lots
|
||||||
|
self.lot = lot
|
||||||
self.lot_id = lot_id
|
self.lot_id = lot_id
|
||||||
|
if self.lot_id and not self.lot:
|
||||||
|
self.lot = self.lots.filter(Lot.id == self.lot_id).one()
|
||||||
self._get_types()
|
self._get_types()
|
||||||
|
|
||||||
def _get_types(self):
|
def _get_types(self):
|
||||||
|
@ -165,8 +178,7 @@ class FilterForm(FlaskForm):
|
||||||
self.filter.data = self.device_type
|
self.filter.data = self.device_type
|
||||||
|
|
||||||
def filter_from_lots(self):
|
def filter_from_lots(self):
|
||||||
if self.lot_id:
|
if self.lot:
|
||||||
self.lot = self.lots.filter(Lot.id == self.lot_id).one()
|
|
||||||
device_ids = (d.id for d in self.lot.devices)
|
device_ids = (d.id for d in self.lot.devices)
|
||||||
self.devices = Device.query.filter(Device.id.in_(device_ids)).filter(
|
self.devices = Device.query.filter(Device.id.in_(device_ids)).filter(
|
||||||
Device.binding == None # noqa: E711
|
Device.binding == None # noqa: E711
|
||||||
|
@ -246,7 +258,8 @@ class LotForm(FlaskForm):
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
def remove(self):
|
def remove(self):
|
||||||
if self.instance and not self.instance.trade:
|
shared = ShareLot.query.filter_by(lot=self.instance).first()
|
||||||
|
if self.instance and not self.instance.trade and not shared:
|
||||||
self.instance.delete()
|
self.instance.delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return self.instance
|
return self.instance
|
||||||
|
@ -459,8 +472,6 @@ class NewDeviceForm(FlaskForm):
|
||||||
if self._obj.placeholder.is_abstract:
|
if self._obj.placeholder.is_abstract:
|
||||||
self.type.render_kw = disabled
|
self.type.render_kw = disabled
|
||||||
self.amount.render_kw = disabled
|
self.amount.render_kw = disabled
|
||||||
# self.id_device_supplier.render_kw = disabled
|
|
||||||
self.pallet.render_kw = disabled
|
|
||||||
self.info.render_kw = disabled
|
self.info.render_kw = disabled
|
||||||
self.components.render_kw = disabled
|
self.components.render_kw = disabled
|
||||||
self.serial_number.render_kw = disabled
|
self.serial_number.render_kw = disabled
|
||||||
|
@ -674,6 +685,14 @@ class NewDeviceForm(FlaskForm):
|
||||||
):
|
):
|
||||||
self._obj.set_functionality(self.functionality.data)
|
self._obj.set_functionality(self.functionality.data)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._obj.placeholder.id_device_supplier = (
|
||||||
|
self.id_device_supplier.data or None
|
||||||
|
)
|
||||||
|
self._obj.placeholder.id_device_internal = (
|
||||||
|
self.id_device_internal.data or None
|
||||||
|
)
|
||||||
|
self._obj.placeholder.pallet = self.pallet.data or None
|
||||||
placeholder_log = PlaceholdersLog(
|
placeholder_log = PlaceholdersLog(
|
||||||
type="Update", source='Web form', placeholder=self._obj.placeholder
|
type="Update", source='Web form', placeholder=self._obj.placeholder
|
||||||
)
|
)
|
||||||
|
@ -1275,8 +1294,24 @@ class TradeDocumentForm(FlaskForm):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
lot_id = kwargs.pop('lot')
|
lot_id = kwargs.pop('lot')
|
||||||
super().__init__(*args, **kwargs)
|
doc_id = kwargs.pop('document', None)
|
||||||
self._lot = Lot.query.filter(Lot.id == lot_id).one()
|
self._lot = Lot.query.filter(Lot.id == lot_id).one()
|
||||||
|
self._obj = None
|
||||||
|
if doc_id:
|
||||||
|
self._obj = TradeDocument.query.filter_by(
|
||||||
|
id=doc_id, lot=self._lot, owner=g.user
|
||||||
|
).one()
|
||||||
|
kwargs['obj'] = self._obj
|
||||||
|
|
||||||
|
if not self.file_name.args:
|
||||||
|
self.file_name.args = ("File", [validators.DataRequired()])
|
||||||
|
if doc_id:
|
||||||
|
self.file_name.args = ()
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self._obj:
|
||||||
|
if isinstance(self.url.data, URL):
|
||||||
|
self.url.data = self.url.data.to_text()
|
||||||
|
|
||||||
if not self._lot.transfer:
|
if not self._lot.transfer:
|
||||||
self.form_errors = ['Error, this lot is not a transfer lot.']
|
self.form_errors = ['Error, this lot is not a transfer lot.']
|
||||||
|
@ -1292,22 +1327,143 @@ class TradeDocumentForm(FlaskForm):
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
file_name = ''
|
file_name = ''
|
||||||
file_hash = ''
|
file_hash = ''
|
||||||
|
if self._obj:
|
||||||
|
file_name = self._obj.file_name
|
||||||
|
file_hash = self._obj.file_hash
|
||||||
|
|
||||||
if self.file_name.data:
|
if self.file_name.data:
|
||||||
file_name = self.file_name.data.filename
|
file_name = self.file_name.data.filename
|
||||||
file_hash = insert_hash(self.file_name.data.read(), commit=False)
|
file_hash = insert_hash(self.file_name.data.read(), commit=False)
|
||||||
|
|
||||||
self.url.data = URL(self.url.data)
|
self.url.data = URL(self.url.data)
|
||||||
self._obj = TradeDocument(lot_id=self._lot.id)
|
if not self._obj:
|
||||||
|
self._obj = TradeDocument(lot_id=self._lot.id)
|
||||||
|
|
||||||
self.populate_obj(self._obj)
|
self.populate_obj(self._obj)
|
||||||
|
|
||||||
self._obj.file_name = file_name
|
self._obj.file_name = file_name
|
||||||
self._obj.file_hash = file_hash
|
self._obj.file_hash = file_hash
|
||||||
db.session.add(self._obj)
|
|
||||||
self._lot.documents.add(self._obj)
|
if not self._obj.id:
|
||||||
|
db.session.add(self._obj)
|
||||||
|
self._lot.documents.add(self._obj)
|
||||||
|
|
||||||
if commit:
|
if commit:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return self._obj
|
return self._obj
|
||||||
|
|
||||||
|
def remove(self):
|
||||||
|
if self._obj:
|
||||||
|
self._obj.delete()
|
||||||
|
db.session.commit()
|
||||||
|
return self._obj
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDocumentForm(FlaskForm):
|
||||||
|
url = URLField(
|
||||||
|
'Url',
|
||||||
|
[validators.Optional()],
|
||||||
|
render_kw={'class': "form-control"},
|
||||||
|
description="Url where the document resides",
|
||||||
|
)
|
||||||
|
description = StringField(
|
||||||
|
'Description',
|
||||||
|
[validators.Optional()],
|
||||||
|
render_kw={'class': "form-control"},
|
||||||
|
description="",
|
||||||
|
)
|
||||||
|
id_document = StringField(
|
||||||
|
'Document Id',
|
||||||
|
[validators.Optional()],
|
||||||
|
render_kw={'class': "form-control"},
|
||||||
|
description="Identification number of document",
|
||||||
|
)
|
||||||
|
type = SelectField(
|
||||||
|
'Type',
|
||||||
|
[validators.Optional()],
|
||||||
|
choices=TYPES_DOCUMENTS,
|
||||||
|
default="",
|
||||||
|
render_kw={'class': "form-select"},
|
||||||
|
)
|
||||||
|
date = DateField(
|
||||||
|
'Date',
|
||||||
|
[validators.Optional()],
|
||||||
|
render_kw={'class': "form-control"},
|
||||||
|
description="",
|
||||||
|
)
|
||||||
|
file_name = FileField(
|
||||||
|
'File',
|
||||||
|
[validators.DataRequired()],
|
||||||
|
render_kw={'class': "form-control"},
|
||||||
|
description="""This file is not stored on our servers, it is only used to
|
||||||
|
generate a digital signature and obtain the name of the file.""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
id = kwargs.pop('dhid')
|
||||||
|
doc_id = kwargs.pop('document', None)
|
||||||
|
self._device = Device.query.filter(Device.devicehub_id == id).first()
|
||||||
|
self._obj = None
|
||||||
|
if doc_id:
|
||||||
|
self._obj = DeviceDocument.query.filter_by(
|
||||||
|
id=doc_id, device=self._device, owner=g.user
|
||||||
|
).one()
|
||||||
|
kwargs['obj'] = self._obj
|
||||||
|
|
||||||
|
if not self.file_name.args:
|
||||||
|
self.file_name.args = ("File", [validators.DataRequired()])
|
||||||
|
if doc_id:
|
||||||
|
self.file_name.args = ()
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self._obj:
|
||||||
|
if isinstance(self.url.data, URL):
|
||||||
|
self.url.data = self.url.data.to_text()
|
||||||
|
|
||||||
|
def validate(self, extra_validators=None):
|
||||||
|
is_valid = super().validate(extra_validators)
|
||||||
|
|
||||||
|
if g.user != self._device.owner:
|
||||||
|
is_valid = False
|
||||||
|
|
||||||
|
return is_valid
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
file_name = ''
|
||||||
|
file_hash = ''
|
||||||
|
if self._obj:
|
||||||
|
file_name = self._obj.file_name
|
||||||
|
file_hash = self._obj.file_hash
|
||||||
|
|
||||||
|
if self.file_name.data:
|
||||||
|
file_name = self.file_name.data.filename
|
||||||
|
file_hash = insert_hash(self.file_name.data.read(), commit=False)
|
||||||
|
|
||||||
|
self.url.data = URL(self.url.data)
|
||||||
|
if not self._obj:
|
||||||
|
self._obj = DeviceDocument(device_id=self._device.id)
|
||||||
|
|
||||||
|
self.populate_obj(self._obj)
|
||||||
|
|
||||||
|
self._obj.file_name = file_name
|
||||||
|
self._obj.file_hash = file_hash
|
||||||
|
|
||||||
|
if not self._obj.id:
|
||||||
|
db.session.add(self._obj)
|
||||||
|
# self._device.documents.add(self._obj)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return self._obj
|
||||||
|
|
||||||
|
def remove(self):
|
||||||
|
if self._obj:
|
||||||
|
self._obj.delete()
|
||||||
|
db.session.commit()
|
||||||
|
return self._obj
|
||||||
|
|
||||||
|
|
||||||
class TransferForm(FlaskForm):
|
class TransferForm(FlaskForm):
|
||||||
lot_name = StringField(
|
lot_name = StringField(
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
|
from dateutil.tz import tzutc
|
||||||
from flask import g
|
from flask import g
|
||||||
from sqlalchemy import Column, Integer
|
from sortedcontainers import SortedSet
|
||||||
|
from sqlalchemy import BigInteger, Column, Integer
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref, relationship
|
from sqlalchemy.orm import backref, relationship
|
||||||
from teal.db import CASCADE_OWN, URL
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
|
||||||
|
|
||||||
|
|
||||||
class Transfer(Thing):
|
class Transfer(Thing):
|
||||||
|
@ -110,3 +112,50 @@ class TransferCustomerDetails(Thing):
|
||||||
),
|
),
|
||||||
primaryjoin='TransferCustomerDetails.transfer_id == Transfer.id',
|
primaryjoin='TransferCustomerDetails.transfer_id == Transfer.id',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_sorted_documents = {
|
||||||
|
'order_by': lambda: DeviceDocument.created,
|
||||||
|
'collection_class': SortedSet,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDocument(Thing):
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||||
|
type = Column(db.CIText(), nullable=True)
|
||||||
|
date = Column(db.DateTime, nullable=True)
|
||||||
|
id_document = Column(db.CIText(), nullable=True)
|
||||||
|
description = Column(db.CIText(), nullable=True)
|
||||||
|
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)
|
||||||
|
device_id = db.Column(BigInteger, db.ForeignKey('device.id'), nullable=False)
|
||||||
|
device = db.relationship(
|
||||||
|
'Device',
|
||||||
|
primaryjoin='DeviceDocument.device_id == Device.id',
|
||||||
|
backref=backref(
|
||||||
|
'documents', lazy=True, cascade=CASCADE_OWN, **_sorted_documents
|
||||||
|
),
|
||||||
|
)
|
||||||
|
file_name = Column(db.CIText(), nullable=True)
|
||||||
|
file_hash = Column(db.CIText(), nullable=True)
|
||||||
|
url = db.Column(URL(), nullable=True)
|
||||||
|
|
||||||
|
# __table_args__ = (
|
||||||
|
# db.Index('document_id', id, postgresql_using='hash'),
|
||||||
|
# db.Index('type_doc', type, postgresql_using='hash')
|
||||||
|
# )
|
||||||
|
|
||||||
|
def get_url(self) -> str:
|
||||||
|
if self.url:
|
||||||
|
return self.url.to_text()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
return self.created.replace(tzinfo=tzutc()) < other.created.replace(
|
||||||
|
tzinfo=tzutc()
|
||||||
|
)
|
||||||
|
|
|
@ -14,6 +14,7 @@ from flask import current_app as app
|
||||||
from flask import g, make_response, request, url_for
|
from flask import g, make_response, request, url_for
|
||||||
from flask.views import View
|
from flask.views import View
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
|
from sqlalchemy import or_
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
from ereuse_devicehub import messages
|
from ereuse_devicehub import messages
|
||||||
|
@ -24,6 +25,7 @@ from ereuse_devicehub.inventory.forms import (
|
||||||
BindingForm,
|
BindingForm,
|
||||||
CustomerDetailsForm,
|
CustomerDetailsForm,
|
||||||
DataWipeForm,
|
DataWipeForm,
|
||||||
|
DeviceDocumentForm,
|
||||||
EditTransferForm,
|
EditTransferForm,
|
||||||
FilterForm,
|
FilterForm,
|
||||||
LotForm,
|
LotForm,
|
||||||
|
@ -50,7 +52,7 @@ from ereuse_devicehub.resources.device.models import (
|
||||||
from ereuse_devicehub.resources.documents.device_row import ActionRow, DeviceRow
|
from ereuse_devicehub.resources.documents.device_row import ActionRow, DeviceRow
|
||||||
from ereuse_devicehub.resources.enums import SnapshotSoftware
|
from ereuse_devicehub.resources.enums import SnapshotSoftware
|
||||||
from ereuse_devicehub.resources.hash_reports import insert_hash
|
from ereuse_devicehub.resources.hash_reports import insert_hash
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot, ShareLot
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
from ereuse_devicehub.views import GenericMixin
|
from ereuse_devicehub.views import GenericMixin
|
||||||
|
|
||||||
|
@ -72,19 +74,25 @@ class DeviceListMixin(GenericMixin):
|
||||||
per_page = int(request.args.get('per_page', PER_PAGE))
|
per_page = int(request.args.get('per_page', PER_PAGE))
|
||||||
filter = request.args.get('filter', "All+Computers")
|
filter = request.args.get('filter', "All+Computers")
|
||||||
|
|
||||||
|
lot = None
|
||||||
|
|
||||||
|
share_lots = self.context['share_lots']
|
||||||
|
share_lot = share_lots.filter_by(lot_id=lot_id).first()
|
||||||
|
if share_lot:
|
||||||
|
lot = share_lot.lot
|
||||||
|
|
||||||
lots = self.context['lots']
|
lots = self.context['lots']
|
||||||
form_filter = FilterForm(lots, lot_id, all_devices=all_devices)
|
form_filter = FilterForm(lots, lot, lot_id, all_devices=all_devices)
|
||||||
devices = form_filter.search().paginate(page=page, per_page=per_page)
|
devices = form_filter.search().paginate(page=page, per_page=per_page)
|
||||||
devices.first = per_page * devices.page - per_page + 1
|
devices.first = per_page * devices.page - per_page + 1
|
||||||
devices.last = len(devices.items) + devices.first - 1
|
devices.last = len(devices.items) + devices.first - 1
|
||||||
|
|
||||||
lot = None
|
|
||||||
form_transfer = ''
|
form_transfer = ''
|
||||||
form_delivery = ''
|
form_delivery = ''
|
||||||
form_receiver = ''
|
form_receiver = ''
|
||||||
form_customer_details = ''
|
form_customer_details = ''
|
||||||
|
|
||||||
if lot_id:
|
if lot_id and not lot:
|
||||||
lot = lots.filter(Lot.id == lot_id).one()
|
lot = lots.filter(Lot.id == lot_id).one()
|
||||||
if not lot.is_temporary and lot.transfer:
|
if not lot.is_temporary and lot.transfer:
|
||||||
form_transfer = EditTransferForm(lot_id=lot.id)
|
form_transfer = EditTransferForm(lot_id=lot.id)
|
||||||
|
@ -110,6 +118,7 @@ class DeviceListMixin(GenericMixin):
|
||||||
'list_devices': self.get_selected_devices(form_new_action),
|
'list_devices': self.get_selected_devices(form_new_action),
|
||||||
'all_devices': all_devices,
|
'all_devices': all_devices,
|
||||||
'filter': filter,
|
'filter': filter,
|
||||||
|
'share_lots': share_lots,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -536,8 +545,9 @@ class LotDeleteView(View):
|
||||||
|
|
||||||
def dispatch_request(self, id):
|
def dispatch_request(self, id):
|
||||||
form = LotForm(id=id)
|
form = LotForm(id=id)
|
||||||
if form.instance.trade:
|
shared = ShareLot.query.filter_by(lot=form.instance).first()
|
||||||
msg = "Sorry, the lot cannot be deleted because have a trade action "
|
if form.instance.trade or shared:
|
||||||
|
msg = "Sorry, the lot cannot be deleted because this lot is share"
|
||||||
messages.error(msg)
|
messages.error(msg)
|
||||||
next_url = url_for('inventory.lotdevicelist', lot_id=id)
|
next_url = url_for('inventory.lotdevicelist', lot_id=id)
|
||||||
return flask.redirect(next_url)
|
return flask.redirect(next_url)
|
||||||
|
@ -547,6 +557,27 @@ class LotDeleteView(View):
|
||||||
return flask.redirect(next_url)
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentDeleteView(View):
|
||||||
|
methods = ['GET']
|
||||||
|
decorators = [login_required]
|
||||||
|
template_name = 'inventory/device_list.html'
|
||||||
|
form_class = TradeDocumentForm
|
||||||
|
|
||||||
|
def dispatch_request(self, lot_id, doc_id):
|
||||||
|
next_url = url_for('inventory.lotdevicelist', lot_id=lot_id)
|
||||||
|
form = self.form_class(lot=lot_id, document=doc_id)
|
||||||
|
try:
|
||||||
|
form.remove()
|
||||||
|
except Exception as err:
|
||||||
|
msg = "{}".format(err)
|
||||||
|
messages.error(msg)
|
||||||
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
msg = "Document removed successfully."
|
||||||
|
messages.success(msg)
|
||||||
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
class UploadSnapshotView(GenericMixin):
|
class UploadSnapshotView(GenericMixin):
|
||||||
methods = ['GET', 'POST']
|
methods = ['GET', 'POST']
|
||||||
decorators = [login_required]
|
decorators = [login_required]
|
||||||
|
@ -789,6 +820,69 @@ class NewTradeView(DeviceListMixin, NewActionView):
|
||||||
return flask.redirect(next_url)
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
|
class NewDeviceDocumentView(GenericMixin):
|
||||||
|
methods = ['POST', 'GET']
|
||||||
|
decorators = [login_required]
|
||||||
|
template_name = 'inventory/device_document.html'
|
||||||
|
form_class = DeviceDocumentForm
|
||||||
|
title = "Add new document"
|
||||||
|
|
||||||
|
def dispatch_request(self, dhid):
|
||||||
|
self.form = self.form_class(dhid=dhid)
|
||||||
|
self.get_context()
|
||||||
|
|
||||||
|
if self.form.validate_on_submit():
|
||||||
|
self.form.save()
|
||||||
|
messages.success('Document created successfully!')
|
||||||
|
next_url = url_for('inventory.device_details', id=dhid)
|
||||||
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
self.context.update({'form': self.form, 'title': self.title})
|
||||||
|
return flask.render_template(self.template_name, **self.context)
|
||||||
|
|
||||||
|
|
||||||
|
class EditDeviceDocumentView(GenericMixin):
|
||||||
|
decorators = [login_required]
|
||||||
|
methods = ['POST', 'GET']
|
||||||
|
template_name = 'inventory/device_document.html'
|
||||||
|
form_class = DeviceDocumentForm
|
||||||
|
title = "Edit document"
|
||||||
|
|
||||||
|
def dispatch_request(self, dhid, doc_id):
|
||||||
|
self.form = self.form_class(dhid=dhid, document=doc_id)
|
||||||
|
self.get_context()
|
||||||
|
|
||||||
|
if self.form.validate_on_submit():
|
||||||
|
self.form.save()
|
||||||
|
messages.success('Edit document successfully!')
|
||||||
|
next_url = url_for('inventory.device_details', id=dhid)
|
||||||
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
self.context.update({'form': self.form, 'title': self.title})
|
||||||
|
return flask.render_template(self.template_name, **self.context)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDocumentDeleteView(View):
|
||||||
|
methods = ['GET']
|
||||||
|
decorators = [login_required]
|
||||||
|
template_name = 'inventory/device_detail.html'
|
||||||
|
form_class = DeviceDocumentForm
|
||||||
|
|
||||||
|
def dispatch_request(self, dhid, doc_id):
|
||||||
|
self.form = self.form_class(dhid=dhid, document=doc_id)
|
||||||
|
next_url = url_for('inventory.device_details', id=dhid)
|
||||||
|
try:
|
||||||
|
self.form.remove()
|
||||||
|
except Exception as err:
|
||||||
|
msg = "{}".format(err)
|
||||||
|
messages.error(msg)
|
||||||
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
msg = "Document removed successfully."
|
||||||
|
messages.success(msg)
|
||||||
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
class NewTradeDocumentView(GenericMixin):
|
class NewTradeDocumentView(GenericMixin):
|
||||||
methods = ['POST', 'GET']
|
methods = ['POST', 'GET']
|
||||||
decorators = [login_required]
|
decorators = [login_required]
|
||||||
|
@ -810,6 +904,27 @@ class NewTradeDocumentView(GenericMixin):
|
||||||
return flask.render_template(self.template_name, **self.context)
|
return flask.render_template(self.template_name, **self.context)
|
||||||
|
|
||||||
|
|
||||||
|
class EditTransferDocumentView(GenericMixin):
|
||||||
|
decorators = [login_required]
|
||||||
|
methods = ['POST', 'GET']
|
||||||
|
template_name = 'inventory/trade_document.html'
|
||||||
|
form_class = TradeDocumentForm
|
||||||
|
title = "Edit document"
|
||||||
|
|
||||||
|
def dispatch_request(self, lot_id, doc_id):
|
||||||
|
self.form = self.form_class(lot=lot_id, document=doc_id)
|
||||||
|
self.get_context()
|
||||||
|
|
||||||
|
if self.form.validate_on_submit():
|
||||||
|
self.form.save()
|
||||||
|
messages.success('Edit document successfully!')
|
||||||
|
next_url = url_for('inventory.lotdevicelist', lot_id=lot_id)
|
||||||
|
return flask.redirect(next_url)
|
||||||
|
|
||||||
|
self.context.update({'form': self.form, 'title': self.title})
|
||||||
|
return flask.render_template(self.template_name, **self.context)
|
||||||
|
|
||||||
|
|
||||||
class NewTransferView(GenericMixin):
|
class NewTransferView(GenericMixin):
|
||||||
methods = ['POST', 'GET']
|
methods = ['POST', 'GET']
|
||||||
template_name = 'inventory/new_transfer.html'
|
template_name = 'inventory/new_transfer.html'
|
||||||
|
@ -899,9 +1014,20 @@ class ExportsView(View):
|
||||||
return export_ids[export_id]()
|
return export_ids[export_id]()
|
||||||
|
|
||||||
def find_devices(self):
|
def find_devices(self):
|
||||||
|
sql = """
|
||||||
|
select lot_device.device_id as id from {schema}.share_lot as share
|
||||||
|
inner join {schema}.lot_device as lot_device
|
||||||
|
on share.lot_id=lot_device.lot_id
|
||||||
|
where share.user_to_id='{user_id}'
|
||||||
|
""".format(
|
||||||
|
schema=app.config.get('SCHEMA'), user_id=g.user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
shared = (x[0] for x in db.session.execute(sql))
|
||||||
|
|
||||||
args = request.args.get('ids')
|
args = request.args.get('ids')
|
||||||
ids = args.split(',') if args else []
|
ids = args.split(',') if args else []
|
||||||
query = Device.query.filter(Device.owner == g.user)
|
query = Device.query.filter(or_(Device.owner == g.user, Device.id.in_(shared)))
|
||||||
return query.filter(Device.devicehub_id.in_(ids))
|
return query.filter(Device.devicehub_id.in_(ids))
|
||||||
|
|
||||||
def response_csv(self, data, name):
|
def response_csv(self, data, name):
|
||||||
|
@ -1149,7 +1275,7 @@ class ExportsView(View):
|
||||||
n_computers = len({x.parent for x in erasures} - erasures_host)
|
n_computers = len({x.parent for x in erasures} - erasures_host)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'title': 'Erasure Certificate',
|
'title': 'Device Sanitization',
|
||||||
'erasures': tuple(erasures),
|
'erasures': tuple(erasures),
|
||||||
'url_pdf': '',
|
'url_pdf': '',
|
||||||
'date_report': '{:%c}'.format(datetime.datetime.now()),
|
'date_report': '{:%c}'.format(datetime.datetime.now()),
|
||||||
|
@ -1196,12 +1322,18 @@ class ExportsView(View):
|
||||||
'Receiver Note Date',
|
'Receiver Note Date',
|
||||||
'Receiver Note Units',
|
'Receiver Note Units',
|
||||||
'Receiver Note Weight',
|
'Receiver Note Weight',
|
||||||
|
'Customer Company Name',
|
||||||
|
'Customer Location',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
for lot in Lot.query.filter_by(owner=g.user):
|
all_lots = set(Lot.query.filter_by(owner=g.user).all())
|
||||||
|
share_lots = [s.lot for s in ShareLot.query.filter_by(user_to=g.user)]
|
||||||
|
all_lots = all_lots.union(share_lots)
|
||||||
|
for lot in all_lots:
|
||||||
delivery_note = lot.transfer and lot.transfer.delivery_note or ''
|
delivery_note = lot.transfer and lot.transfer.delivery_note or ''
|
||||||
receiver_note = lot.transfer and lot.transfer.receiver_note or ''
|
receiver_note = lot.transfer and lot.transfer.receiver_note or ''
|
||||||
|
customer = lot.transfer and lot.transfer.customer_details or ''
|
||||||
wb_devs = 0
|
wb_devs = 0
|
||||||
placeholders = 0
|
placeholders = 0
|
||||||
|
|
||||||
|
@ -1214,10 +1346,13 @@ class ExportsView(View):
|
||||||
elif snapshots[-1].software in [SnapshotSoftware.Workbench]:
|
elif snapshots[-1].software in [SnapshotSoftware.Workbench]:
|
||||||
wb_devs += 1
|
wb_devs += 1
|
||||||
|
|
||||||
|
type_lot = lot.type_transfer()
|
||||||
|
if lot in share_lots:
|
||||||
|
type_lot = "Shared"
|
||||||
row = [
|
row = [
|
||||||
lot.id,
|
lot.id,
|
||||||
lot.name,
|
lot.name,
|
||||||
lot.type_transfer(),
|
type_lot,
|
||||||
lot.transfer and (lot.transfer.closed and 'Closed' or 'Open') or '',
|
lot.transfer and (lot.transfer.closed and 'Closed' or 'Open') or '',
|
||||||
lot.transfer and lot.transfer.code or '',
|
lot.transfer and lot.transfer.code or '',
|
||||||
lot.transfer and lot.transfer.date or '',
|
lot.transfer and lot.transfer.date or '',
|
||||||
|
@ -1235,6 +1370,8 @@ class ExportsView(View):
|
||||||
receiver_note and receiver_note.date or '',
|
receiver_note and receiver_note.date or '',
|
||||||
receiver_note and receiver_note.units or '',
|
receiver_note and receiver_note.units or '',
|
||||||
receiver_note and receiver_note.weight or '',
|
receiver_note and receiver_note.weight or '',
|
||||||
|
customer and customer.company_name or '',
|
||||||
|
customer and customer.location or '',
|
||||||
]
|
]
|
||||||
cw.writerow(row)
|
cw.writerow(row)
|
||||||
|
|
||||||
|
@ -1264,11 +1401,14 @@ class ExportsView(View):
|
||||||
|
|
||||||
for dev in self.find_devices():
|
for dev in self.find_devices():
|
||||||
for lot in dev.lots:
|
for lot in dev.lots:
|
||||||
|
type_lot = lot.type_transfer()
|
||||||
|
if lot.is_shared:
|
||||||
|
type_lot = "Shared"
|
||||||
row = [
|
row = [
|
||||||
dev.devicehub_id,
|
dev.devicehub_id,
|
||||||
lot.id,
|
lot.id,
|
||||||
lot.name,
|
lot.name,
|
||||||
lot.type_transfer(),
|
type_lot,
|
||||||
lot.transfer and (lot.transfer.closed and 'Closed' or 'Open') or '',
|
lot.transfer and (lot.transfer.closed and 'Closed' or 'Open') or '',
|
||||||
lot.transfer and lot.transfer.code or '',
|
lot.transfer and lot.transfer.code or '',
|
||||||
lot.transfer and lot.transfer.date or '',
|
lot.transfer and lot.transfer.date or '',
|
||||||
|
@ -1512,8 +1652,28 @@ devices.add_url_rule(
|
||||||
'/action/datawipe/add/', view_func=NewDataWipeView.as_view('datawipe_add')
|
'/action/datawipe/add/', view_func=NewDataWipeView.as_view('datawipe_add')
|
||||||
)
|
)
|
||||||
devices.add_url_rule(
|
devices.add_url_rule(
|
||||||
'/lot/<string:lot_id>/trade-document/add/',
|
'/device/<string:dhid>/document/add/',
|
||||||
view_func=NewTradeDocumentView.as_view('trade_document_add'),
|
view_func=NewDeviceDocumentView.as_view('device_document_add'),
|
||||||
|
)
|
||||||
|
devices.add_url_rule(
|
||||||
|
'/device/<string:dhid>/document/edit/<string:doc_id>',
|
||||||
|
view_func=EditDeviceDocumentView.as_view('device_document_edit'),
|
||||||
|
)
|
||||||
|
devices.add_url_rule(
|
||||||
|
'/device/<string:dhid>/document/del/<string:doc_id>',
|
||||||
|
view_func=DeviceDocumentDeleteView.as_view('device_document_del'),
|
||||||
|
)
|
||||||
|
devices.add_url_rule(
|
||||||
|
'/lot/<string:lot_id>/transfer-document/add/',
|
||||||
|
view_func=NewTradeDocumentView.as_view('transfer_document_add'),
|
||||||
|
)
|
||||||
|
devices.add_url_rule(
|
||||||
|
'/lot/<string:lot_id>/document/edit/<string:doc_id>',
|
||||||
|
view_func=EditTransferDocumentView.as_view('transfer_document_edit'),
|
||||||
|
)
|
||||||
|
devices.add_url_rule(
|
||||||
|
'/lot/<string:lot_id>/document/del/<string:doc_id>',
|
||||||
|
view_func=DocumentDeleteView.as_view('document_del'),
|
||||||
)
|
)
|
||||||
devices.add_url_rule('/device/', view_func=DeviceListView.as_view('devicelist'))
|
devices.add_url_rule('/device/', view_func=DeviceListView.as_view('devicelist'))
|
||||||
devices.add_url_rule(
|
devices.add_url_rule(
|
||||||
|
|
|
@ -8,7 +8,7 @@ from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from ereuse_devicehub import __version__, messages
|
from ereuse_devicehub import __version__, messages
|
||||||
from ereuse_devicehub.labels.forms import PrintLabelsForm, TagForm, TagUnnamedForm
|
from ereuse_devicehub.labels.forms import PrintLabelsForm, TagForm, TagUnnamedForm
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot, ShareLot
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
|
|
||||||
labels = Blueprint('labels', __name__, url_prefix='/labels')
|
labels = Blueprint('labels', __name__, url_prefix='/labels')
|
||||||
|
@ -23,6 +23,7 @@ class TagListView(View):
|
||||||
|
|
||||||
def dispatch_request(self):
|
def dispatch_request(self):
|
||||||
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
||||||
|
share_lots = ShareLot.query.filter_by(user_to_id=current_user.id)
|
||||||
tags = Tag.query.filter(Tag.owner_id == current_user.id).order_by(
|
tags = Tag.query.filter(Tag.owner_id == current_user.id).order_by(
|
||||||
Tag.created.desc()
|
Tag.created.desc()
|
||||||
)
|
)
|
||||||
|
@ -31,6 +32,7 @@ class TagListView(View):
|
||||||
'tags': tags,
|
'tags': tags,
|
||||||
'page_title': 'Unique Identifiers Management',
|
'page_title': 'Unique Identifiers Management',
|
||||||
'version': __version__,
|
'version': __version__,
|
||||||
|
'share_lots': share_lots,
|
||||||
}
|
}
|
||||||
return flask.render_template(self.template_name, **context)
|
return flask.render_template(self.template_name, **context)
|
||||||
|
|
||||||
|
@ -42,7 +44,13 @@ class TagAddView(View):
|
||||||
|
|
||||||
def dispatch_request(self):
|
def dispatch_request(self):
|
||||||
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
||||||
context = {'page_title': 'New Tag', 'lots': lots, 'version': __version__}
|
share_lots = ShareLot.query.filter_by(user_to_id=current_user.id)
|
||||||
|
context = {
|
||||||
|
'page_title': 'New Tag',
|
||||||
|
'lots': lots,
|
||||||
|
'version': __version__,
|
||||||
|
'share_lots': share_lots,
|
||||||
|
}
|
||||||
form = TagForm()
|
form = TagForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
form.save()
|
form.save()
|
||||||
|
@ -59,10 +67,12 @@ class TagAddUnnamedView(View):
|
||||||
|
|
||||||
def dispatch_request(self):
|
def dispatch_request(self):
|
||||||
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
||||||
|
share_lots = ShareLot.query.filter_by(user_to_id=current_user.id)
|
||||||
context = {
|
context = {
|
||||||
'page_title': 'New Unnamed Tag',
|
'page_title': 'New Unnamed Tag',
|
||||||
'lots': lots,
|
'lots': lots,
|
||||||
'version': __version__,
|
'version': __version__,
|
||||||
|
'share_lots': share_lots,
|
||||||
}
|
}
|
||||||
form = TagUnnamedForm()
|
form = TagUnnamedForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
|
@ -94,11 +104,13 @@ class PrintLabelsView(View):
|
||||||
|
|
||||||
def dispatch_request(self):
|
def dispatch_request(self):
|
||||||
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
||||||
|
share_lots = ShareLot.query.filter_by(user_to_id=current_user.id)
|
||||||
context = {
|
context = {
|
||||||
'lots': lots,
|
'lots': lots,
|
||||||
'page_title': self.title,
|
'page_title': self.title,
|
||||||
'version': __version__,
|
'version': __version__,
|
||||||
'referrer': request.referrer,
|
'referrer': request.referrer,
|
||||||
|
'share_lots': share_lots,
|
||||||
}
|
}
|
||||||
|
|
||||||
form = PrintLabelsForm()
|
form = PrintLabelsForm()
|
||||||
|
@ -123,6 +135,7 @@ class LabelDetailView(View):
|
||||||
|
|
||||||
def dispatch_request(self, id):
|
def dispatch_request(self, id):
|
||||||
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
lots = Lot.query.filter(Lot.owner_id == current_user.id)
|
||||||
|
share_lots = ShareLot.query.filter_by(user_to_id=current_user.id)
|
||||||
tag = (
|
tag = (
|
||||||
Tag.query.filter(Tag.owner_id == current_user.id).filter(Tag.id == id).one()
|
Tag.query.filter(Tag.owner_id == current_user.id).filter(Tag.id == id).one()
|
||||||
)
|
)
|
||||||
|
@ -131,6 +144,7 @@ class LabelDetailView(View):
|
||||||
'page_title': self.title,
|
'page_title': self.title,
|
||||||
'version': __version__,
|
'version': __version__,
|
||||||
'referrer': request.referrer,
|
'referrer': request.referrer,
|
||||||
|
'share_lots': share_lots,
|
||||||
}
|
}
|
||||||
|
|
||||||
devices = []
|
devices = []
|
||||||
|
|
|
@ -1,14 +1,33 @@
|
||||||
from marshmallow.fields import missing_
|
from marshmallow.fields import missing_
|
||||||
from teal.db import SQLAlchemy
|
|
||||||
from teal.marshmallow import NestedOn as TealNestedOn
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
from ereuse_devicehub.teal.db import SQLAlchemy
|
||||||
|
from ereuse_devicehub.teal.marshmallow import NestedOn as TealNestedOn
|
||||||
|
|
||||||
|
|
||||||
class NestedOn(TealNestedOn):
|
class NestedOn(TealNestedOn):
|
||||||
__doc__ = TealNestedOn.__doc__
|
__doc__ = TealNestedOn.__doc__
|
||||||
|
|
||||||
def __init__(self, nested, polymorphic_on='type', db: SQLAlchemy = db, collection_class=list,
|
def __init__(
|
||||||
default=missing_, exclude=tuple(), only_query: str = None, only=None, **kwargs):
|
self,
|
||||||
super().__init__(nested, polymorphic_on, db, collection_class, default, exclude,
|
nested,
|
||||||
only_query, only, **kwargs)
|
polymorphic_on='type',
|
||||||
|
db: SQLAlchemy = db,
|
||||||
|
collection_class=list,
|
||||||
|
default=missing_,
|
||||||
|
exclude=tuple(),
|
||||||
|
only_query: str = None,
|
||||||
|
only=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
nested,
|
||||||
|
polymorphic_on,
|
||||||
|
db,
|
||||||
|
collection_class,
|
||||||
|
default,
|
||||||
|
exclude,
|
||||||
|
only_query,
|
||||||
|
only,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
|
@ -9,7 +9,7 @@ from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
import citext
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
${imports if imports else ""}
|
${imports if imports else ""}
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|
|
@ -10,7 +10,7 @@ from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
import citext
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
@ -26,11 +26,32 @@ def get_inv():
|
||||||
raise ValueError("Inventory value is not specified")
|
raise ValueError("Inventory value is not specified")
|
||||||
return INV
|
return INV
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
op.alter_column('test_data_storage', 'current_pending_sector_count', type_=sa.Integer(), schema=f'{get_inv()}')
|
op.alter_column(
|
||||||
op.alter_column('test_data_storage', 'offline_uncorrectable', type_=sa.Integer(), schema=f'{get_inv()}')
|
'test_data_storage',
|
||||||
|
'current_pending_sector_count',
|
||||||
|
type_=sa.Integer(),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
'test_data_storage',
|
||||||
|
'offline_uncorrectable',
|
||||||
|
type_=sa.Integer(),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
op.alter_column('test_data_storage', 'current_pending_sector_count', type_=sa.SmallInteger(), schema=f'{get_inv()}')
|
op.alter_column(
|
||||||
op.alter_column('test_data_storage', 'offline_uncorrectable', type_=sa.SmallInteger(), schema=f'{get_inv()}')
|
'test_data_storage',
|
||||||
|
'current_pending_sector_count',
|
||||||
|
type_=sa.SmallInteger(),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
'test_data_storage',
|
||||||
|
'offline_uncorrectable',
|
||||||
|
type_=sa.SmallInteger(),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
|
@ -11,7 +11,7 @@ from sqlalchemy.dialects import postgresql
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
import citext
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
from ereuse_devicehub.resources.enums import SessionType
|
from ereuse_devicehub.resources.enums import SessionType
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""share lot
|
||||||
|
|
||||||
|
Revision ID: 2f2ef041483a
|
||||||
|
Revises: ac476b60d952
|
||||||
|
Create Date: 2023-04-26 16:04:21.560888
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import context, op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2f2ef041483a'
|
||||||
|
down_revision = 'ac476b60d952'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_inv():
|
||||||
|
INV = context.get_x_argument(as_dictionary=True).get('inventory')
|
||||||
|
if not INV:
|
||||||
|
raise ValueError("Inventory value is not specified")
|
||||||
|
return INV
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'share_lot',
|
||||||
|
sa.Column(
|
||||||
|
'created',
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'updated',
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('user_to_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_to_id'], ['common.user.id']),
|
||||||
|
sa.ForeignKeyConstraint(['lot_id'], [f'{get_inv()}.lot.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('share_lot', schema=f'{get_inv()}')
|
|
@ -5,12 +5,12 @@ Revises: bf600ca861a4
|
||||||
Create Date: 2020-12-16 11:45:13.339624
|
Create Date: 2020-12-16 11:45:13.339624
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from alembic import context
|
import citext
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
from alembic import context
|
||||||
import teal
|
from alembic import op
|
||||||
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
|
|
@ -5,15 +5,14 @@ Revises: 51439cf24be8
|
||||||
Create Date: 2021-06-15 14:38:59.931818
|
Create Date: 2021-06-15 14:38:59.931818
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import teal
|
|
||||||
import citext
|
import citext
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from ereuse_devicehub import teal
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '3a3601ac8224'
|
revision = '3a3601ac8224'
|
||||||
down_revision = '51439cf24be8'
|
down_revision = '51439cf24be8'
|
||||||
|
@ -27,108 +26,143 @@ def get_inv():
|
||||||
raise ValueError("Inventory value is not specified")
|
raise ValueError("Inventory value is not specified")
|
||||||
return INV
|
return INV
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
op.create_table('trade_document',
|
op.create_table(
|
||||||
sa.Column(
|
'trade_document',
|
||||||
'updated',
|
sa.Column(
|
||||||
sa.TIMESTAMP(timezone=True),
|
'updated',
|
||||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
sa.TIMESTAMP(timezone=True),
|
||||||
nullable=False,
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
comment='The last time Devicehub recorded a change for \n this thing.\n '
|
nullable=False,
|
||||||
),
|
comment='The last time Devicehub recorded a change for \n this thing.\n ',
|
||||||
sa.Column(
|
),
|
||||||
'created',
|
sa.Column(
|
||||||
sa.TIMESTAMP(timezone=True),
|
'created',
|
||||||
server_default=sa.text('CURRENT_TIMESTAMP'),
|
sa.TIMESTAMP(timezone=True),
|
||||||
nullable=False,
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
comment='When Devicehub created this.'
|
nullable=False,
|
||||||
),
|
comment='When Devicehub created this.',
|
||||||
sa.Column(
|
),
|
||||||
'id',
|
sa.Column(
|
||||||
sa.BigInteger(),
|
'id',
|
||||||
nullable=False,
|
sa.BigInteger(),
|
||||||
comment='The identifier of the device for this database. Used only\n internally for software; users should not use this.\n '
|
nullable=False,
|
||||||
),
|
comment='The identifier of the device for this database. Used only\n internally for software; users should not use this.\n ',
|
||||||
sa.Column(
|
),
|
||||||
'date',
|
sa.Column(
|
||||||
sa.DateTime(),
|
'date',
|
||||||
nullable=True,
|
sa.DateTime(),
|
||||||
comment='The date of document, some documents need to have one date\n '
|
nullable=True,
|
||||||
),
|
comment='The date of document, some documents need to have one date\n ',
|
||||||
sa.Column(
|
),
|
||||||
'id_document',
|
sa.Column(
|
||||||
citext.CIText(),
|
'id_document',
|
||||||
nullable=True,
|
citext.CIText(),
|
||||||
comment='The id of one document like invoice so they can be linked.'
|
nullable=True,
|
||||||
),
|
comment='The id of one document like invoice so they can be linked.',
|
||||||
sa.Column(
|
),
|
||||||
'description',
|
sa.Column(
|
||||||
citext.CIText(),
|
'description',
|
||||||
nullable=True,
|
citext.CIText(),
|
||||||
comment='A description of document.'
|
nullable=True,
|
||||||
),
|
comment='A description of document.',
|
||||||
sa.Column(
|
),
|
||||||
'owner_id',
|
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
postgresql.UUID(as_uuid=True),
|
sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
nullable=False
|
sa.Column(
|
||||||
),
|
'file_name',
|
||||||
sa.Column(
|
citext.CIText(),
|
||||||
'lot_id',
|
nullable=True,
|
||||||
postgresql.UUID(as_uuid=True),
|
comment='This is the name of the file when user up the document.',
|
||||||
nullable=False
|
),
|
||||||
),
|
sa.Column(
|
||||||
sa.Column(
|
'file_hash',
|
||||||
'file_name',
|
citext.CIText(),
|
||||||
citext.CIText(),
|
nullable=True,
|
||||||
nullable=True,
|
comment='This is the hash of the file produced from frontend.',
|
||||||
comment='This is the name of the file when user up the document.'
|
),
|
||||||
),
|
sa.Column(
|
||||||
sa.Column(
|
'url',
|
||||||
'file_hash',
|
citext.CIText(),
|
||||||
citext.CIText(),
|
teal.db.URL(),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
comment='This is the hash of the file produced from frontend.'
|
comment='This is the url where resides the document.',
|
||||||
),
|
),
|
||||||
sa.Column(
|
sa.ForeignKeyConstraint(
|
||||||
'url',
|
['lot_id'],
|
||||||
citext.CIText(),
|
[f'{get_inv()}.lot.id'],
|
||||||
teal.db.URL(),
|
),
|
||||||
nullable=True,
|
sa.ForeignKeyConstraint(
|
||||||
comment='This is the url where resides the document.'
|
['owner_id'],
|
||||||
),
|
['common.user.id'],
|
||||||
sa.ForeignKeyConstraint(['lot_id'], [f'{get_inv()}.lot.id'],),
|
),
|
||||||
sa.ForeignKeyConstraint(['owner_id'], ['common.user.id'],),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
schema=f'{get_inv()}',
|
||||||
schema=f'{get_inv()}'
|
|
||||||
)
|
)
|
||||||
# Action document table
|
# Action document table
|
||||||
op.create_table('action_trade_document',
|
op.create_table(
|
||||||
sa.Column('document_id', sa.BigInteger(), nullable=False),
|
'action_trade_document',
|
||||||
sa.Column('action_id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('document_id', sa.BigInteger(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['action_id'], [f'{get_inv()}.action.id'], ),
|
sa.Column('action_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['document_id'], [f'{get_inv()}.trade_document.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
sa.PrimaryKeyConstraint('document_id', 'action_id'),
|
['action_id'],
|
||||||
schema=f'{get_inv()}'
|
[f'{get_inv()}.action.id'],
|
||||||
)
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['document_id'],
|
||||||
|
[f'{get_inv()}.trade_document.id'],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint('document_id', 'action_id'),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
op.create_index('document_id', 'trade_document', ['id'], unique=False, postgresql_using='hash', schema=f'{get_inv()}')
|
op.create_index(
|
||||||
op.create_index(op.f('ix_trade_document_created'), 'trade_document', ['created'], unique=False, schema=f'{get_inv()}')
|
'document_id',
|
||||||
op.create_index(op.f('ix_trade_document_updated'), 'trade_document', ['updated'], unique=False, schema=f'{get_inv()}')
|
'trade_document',
|
||||||
|
['id'],
|
||||||
|
unique=False,
|
||||||
|
postgresql_using='hash',
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f('ix_trade_document_created'),
|
||||||
|
'trade_document',
|
||||||
|
['created'],
|
||||||
|
unique=False,
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f('ix_trade_document_updated'),
|
||||||
|
'trade_document',
|
||||||
|
['updated'],
|
||||||
|
unique=False,
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
op.create_table('confirm_document',
|
op.create_table(
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
'confirm_document',
|
||||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column('action_id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('action_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['id'],
|
||||||
|
[f'{get_inv()}.action.id'],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['action_id'],
|
||||||
|
[f'{get_inv()}.action.id'],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['user_id'],
|
||||||
|
['common.user.id'],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['action_id'], [f'{get_inv()}.action.id'], ),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['common.user.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
schema=f'{get_inv()}'
|
|
||||||
)
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
op.drop_table('action_trade_document', schema=f'{get_inv()}')
|
op.drop_table('action_trade_document', schema=f'{get_inv()}')
|
||||||
op.drop_table('confirm_document', schema=f'{get_inv()}')
|
op.drop_table('confirm_document', schema=f'{get_inv()}')
|
||||||
op.drop_table('trade_document', schema=f'{get_inv()}')
|
op.drop_table('trade_document', schema=f'{get_inv()}')
|
||||||
|
|
||||||
|
|
|
@ -7,10 +7,11 @@ Create Date: 2023-02-13 18:01:00.092527
|
||||||
"""
|
"""
|
||||||
import citext
|
import citext
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import teal
|
|
||||||
from alembic import context, op
|
from alembic import context, op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '4f33137586dd'
|
revision = '4f33137586dd'
|
||||||
down_revision = '8334535d56fa'
|
down_revision = '8334535d56fa'
|
||||||
|
|
|
@ -9,7 +9,7 @@ from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
import citext
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
@ -32,51 +32,98 @@ def get_inv():
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# Document table
|
# Document table
|
||||||
op.create_table('document',
|
op.create_table(
|
||||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
'document',
|
||||||
sa.Column('updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'),
|
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||||
nullable=False,
|
sa.Column(
|
||||||
comment='The last time Document recorded a change for \n this thing.\n '),
|
'updated',
|
||||||
sa.Column('created', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'),
|
sa.TIMESTAMP(timezone=True),
|
||||||
nullable=False, comment='When Document created this.'),
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
sa.Column('document_type', sa.Unicode(), nullable=False),
|
nullable=False,
|
||||||
sa.Column('date', sa.TIMESTAMP(timezone=True), nullable=True),
|
comment='The last time Document recorded a change for \n this thing.\n ',
|
||||||
sa.Column('id_document', sa.Unicode(), nullable=True),
|
),
|
||||||
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column(
|
||||||
sa.Column('file_name', sa.Unicode(), nullable=False),
|
'created',
|
||||||
sa.Column('file_hash', sa.Unicode(), nullable=False),
|
sa.TIMESTAMP(timezone=True),
|
||||||
sa.Column('url', sa.Unicode(), nullable=True),
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
|
nullable=False,
|
||||||
sa.ForeignKeyConstraint(['owner_id'], ['common.user.id'], ),
|
comment='When Document created this.',
|
||||||
sa.PrimaryKeyConstraint('id'),
|
),
|
||||||
schema=f'{get_inv()}'
|
sa.Column('document_type', sa.Unicode(), nullable=False),
|
||||||
)
|
sa.Column('date', sa.TIMESTAMP(timezone=True), nullable=True),
|
||||||
op.create_index('generic_document_id', 'document', ['id'], unique=False, postgresql_using='hash', schema=f'{get_inv()}')
|
sa.Column('id_document', sa.Unicode(), nullable=True),
|
||||||
op.create_index(op.f('ix_document_created'), 'document', ['created'], unique=False, schema=f'{get_inv()}')
|
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
op.create_index(op.f('ix_document_updated'), 'document', ['updated'], unique=False, schema=f'{get_inv()}')
|
sa.Column('file_name', sa.Unicode(), nullable=False),
|
||||||
op.create_index('document_type_index', 'document', ['document_type'], unique=False, postgresql_using='hash', schema=f'{get_inv()}')
|
sa.Column('file_hash', sa.Unicode(), nullable=False),
|
||||||
|
sa.Column('url', sa.Unicode(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['owner_id'],
|
||||||
|
['common.user.id'],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'generic_document_id',
|
||||||
|
'document',
|
||||||
|
['id'],
|
||||||
|
unique=False,
|
||||||
|
postgresql_using='hash',
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f('ix_document_created'),
|
||||||
|
'document',
|
||||||
|
['created'],
|
||||||
|
unique=False,
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f('ix_document_updated'),
|
||||||
|
'document',
|
||||||
|
['updated'],
|
||||||
|
unique=False,
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'document_type_index',
|
||||||
|
'document',
|
||||||
|
['document_type'],
|
||||||
|
unique=False,
|
||||||
|
postgresql_using='hash',
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
# DataWipeDocument table
|
# DataWipeDocument table
|
||||||
op.create_table('data_wipe_document',
|
op.create_table(
|
||||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
'data_wipe_document',
|
||||||
sa.Column('software', sa.Unicode(), nullable=True),
|
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||||
sa.Column('success', sa.Boolean(), nullable=False),
|
sa.Column('software', sa.Unicode(), nullable=True),
|
||||||
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.document.id'], ),
|
sa.Column('success', sa.Boolean(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.ForeignKeyConstraint(
|
||||||
schema=f'{get_inv()}'
|
['id'],
|
||||||
)
|
[f'{get_inv()}.document.id'],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
# DataWipe table
|
# DataWipe table
|
||||||
op.create_table('data_wipe',
|
op.create_table(
|
||||||
sa.Column('document_id', sa.BigInteger(), nullable=False),
|
'data_wipe',
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('document_id', sa.BigInteger(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['document_id'], [f'{get_inv()}.document.id'], ),
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
sa.PrimaryKeyConstraint('id'),
|
['document_id'],
|
||||||
schema=f'{get_inv()}'
|
[f'{get_inv()}.document.id'],
|
||||||
)
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['id'],
|
||||||
|
[f'{get_inv()}.action.id'],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|
|
@ -10,7 +10,7 @@ from alembic import context
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
import citext
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
@ -26,10 +26,10 @@ def get_inv():
|
||||||
raise ValueError("Inventory value is not specified")
|
raise ValueError("Inventory value is not specified")
|
||||||
return INV
|
return INV
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
con = op.get_bind()
|
con = op.get_bind()
|
||||||
|
|
||||||
|
|
||||||
confirmsRevokes_sql = f"select * from {get_inv()}.action as action join {get_inv()}.confirm as confirm on action.id=confirm.id where action.type='ConfirmRevoke'"
|
confirmsRevokes_sql = f"select * from {get_inv()}.action as action join {get_inv()}.confirm as confirm on action.id=confirm.id where action.type='ConfirmRevoke'"
|
||||||
revokes_sql = f"select confirm.id, confirm.action_id from {get_inv()}.action as action join {get_inv()}.confirm as confirm on action.id=confirm.id where action.type='Revoke'"
|
revokes_sql = f"select confirm.id, confirm.action_id from {get_inv()}.action as action join {get_inv()}.confirm as confirm on action.id=confirm.id where action.type='Revoke'"
|
||||||
confirmsRevokes = [a for a in con.execute(confirmsRevokes_sql)]
|
confirmsRevokes = [a for a in con.execute(confirmsRevokes_sql)]
|
||||||
|
@ -40,12 +40,12 @@ def upgrade():
|
||||||
revoke_id = ac.action_id
|
revoke_id = ac.action_id
|
||||||
trade_id = revokes[revoke_id]
|
trade_id = revokes[revoke_id]
|
||||||
sql_action = f"update {get_inv()}.action set type='Revoke' where id='{ac_id}'"
|
sql_action = f"update {get_inv()}.action set type='Revoke' where id='{ac_id}'"
|
||||||
sql_confirm = f"update {get_inv()}.confirm set action_id='{trade_id}' where id='{ac_id}'"
|
sql_confirm = (
|
||||||
|
f"update {get_inv()}.confirm set action_id='{trade_id}' where id='{ac_id}'"
|
||||||
|
)
|
||||||
con.execute(sql_action)
|
con.execute(sql_action)
|
||||||
con.execute(sql_confirm)
|
con.execute(sql_confirm)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""add document device
|
||||||
|
|
||||||
|
Revision ID: ac476b60d952
|
||||||
|
Revises: 4f33137586dd
|
||||||
|
Create Date: 2023-03-31 10:46:02.463007
|
||||||
|
|
||||||
|
"""
|
||||||
|
import citext
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import context, op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ac476b60d952'
|
||||||
|
down_revision = '4f33137586dd'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_inv():
|
||||||
|
INV = context.get_x_argument(as_dictionary=True).get('inventory')
|
||||||
|
if not INV:
|
||||||
|
raise ValueError("Inventory value is not specified")
|
||||||
|
return INV
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'device_document',
|
||||||
|
sa.Column(
|
||||||
|
'updated',
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'created',
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text('CURRENT_TIMESTAMP'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'id',
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'type',
|
||||||
|
citext.CIText(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'date',
|
||||||
|
sa.DateTime(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'id_document',
|
||||||
|
citext.CIText(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'description',
|
||||||
|
citext.CIText(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('device_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
'file_name',
|
||||||
|
citext.CIText(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'file_hash',
|
||||||
|
citext.CIText(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'url',
|
||||||
|
citext.CIText(),
|
||||||
|
teal.db.URL(),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['device_id'],
|
||||||
|
[f'{get_inv()}.device.id'],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['owner_id'],
|
||||||
|
['common.user.id'],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('device_document', schema=f'{get_inv()}')
|
|
@ -6,7 +6,7 @@ Create Date: 2020-12-29 20:19:46.981207
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
from alembic import context, op
|
from alembic import context, op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
import citext
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
@ -26,6 +26,7 @@ def get_inv():
|
||||||
raise ValueError("Inventory value is not specified")
|
raise ValueError("Inventory value is not specified")
|
||||||
return INV
|
return INV
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
con = op.get_bind()
|
con = op.get_bind()
|
||||||
sql = f"""
|
sql = f"""
|
||||||
|
@ -60,6 +61,5 @@ def upgrade():
|
||||||
con.execute(sql)
|
con.execute(sql)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -10,7 +10,7 @@ import sqlalchemy as sa
|
||||||
from alembic import context
|
from alembic import context
|
||||||
import sqlalchemy_utils
|
import sqlalchemy_utils
|
||||||
import citext
|
import citext
|
||||||
import teal
|
from ereuse_devicehub import teal
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
|
@ -26,48 +26,85 @@ def get_inv():
|
||||||
raise ValueError("Inventory value is not specified")
|
raise ValueError("Inventory value is not specified")
|
||||||
return INV
|
return INV
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
# Allocate action
|
# Allocate action
|
||||||
op.drop_table('allocate', schema=f'{get_inv()}')
|
op.drop_table('allocate', schema=f'{get_inv()}')
|
||||||
op.create_table('allocate',
|
op.create_table(
|
||||||
sa.Column('final_user_code', citext.CIText(), default='', nullable=True,
|
'allocate',
|
||||||
comment = "This is a internal code for mainteing the secrets of the personal datas of the new holder"),
|
sa.Column(
|
||||||
sa.Column('transaction', citext.CIText(), nullable=True, comment='The code used from the owner for relation with external tool.'),
|
'final_user_code',
|
||||||
|
citext.CIText(),
|
||||||
|
default='',
|
||||||
|
nullable=True,
|
||||||
|
comment="This is a internal code for mainteing the secrets of the personal datas of the new holder",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'transaction',
|
||||||
|
citext.CIText(),
|
||||||
|
nullable=True,
|
||||||
|
comment='The code used from the owner for relation with external tool.',
|
||||||
|
),
|
||||||
sa.Column('end_users', sa.Numeric(precision=4), nullable=True),
|
sa.Column('end_users', sa.Numeric(precision=4), nullable=True),
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
|
['id'],
|
||||||
|
[f'{get_inv()}.action.id'],
|
||||||
|
),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
schema=f'{get_inv()}'
|
schema=f'{get_inv()}',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deallocate action
|
# Deallocate action
|
||||||
op.drop_table('deallocate', schema=f'{get_inv()}')
|
op.drop_table('deallocate', schema=f'{get_inv()}')
|
||||||
op.create_table('deallocate',
|
op.create_table(
|
||||||
sa.Column('transaction', citext.CIText(), nullable=True, comment='The code used from the owner for relation with external tool.'),
|
'deallocate',
|
||||||
|
sa.Column(
|
||||||
|
'transaction',
|
||||||
|
citext.CIText(),
|
||||||
|
nullable=True,
|
||||||
|
comment='The code used from the owner for relation with external tool.',
|
||||||
|
),
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
|
['id'],
|
||||||
|
[f'{get_inv()}.action.id'],
|
||||||
|
),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
schema=f'{get_inv()}'
|
schema=f'{get_inv()}',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add allocate as a column in device
|
# Add allocate as a column in device
|
||||||
op.add_column('device', sa.Column('allocated', sa.Boolean(), nullable=True), schema=f'{get_inv()}')
|
op.add_column(
|
||||||
|
'device',
|
||||||
|
sa.Column('allocated', sa.Boolean(), nullable=True),
|
||||||
|
schema=f'{get_inv()}',
|
||||||
|
)
|
||||||
|
|
||||||
# Receive action
|
# Receive action
|
||||||
op.drop_table('receive', schema=f'{get_inv()}')
|
op.drop_table('receive', schema=f'{get_inv()}')
|
||||||
|
|
||||||
# Live action
|
# Live action
|
||||||
op.drop_table('live', schema=f'{get_inv()}')
|
op.drop_table('live', schema=f'{get_inv()}')
|
||||||
op.create_table('live',
|
op.create_table(
|
||||||
|
'live',
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column('serial_number', sa.Unicode(), nullable=True,
|
sa.Column(
|
||||||
comment='The serial number of the Hard Disk in lower case.'),
|
'serial_number',
|
||||||
|
sa.Unicode(),
|
||||||
|
nullable=True,
|
||||||
|
comment='The serial number of the Hard Disk in lower case.',
|
||||||
|
),
|
||||||
sa.Column('usage_time_hdd', sa.Interval(), nullable=True),
|
sa.Column('usage_time_hdd', sa.Interval(), nullable=True),
|
||||||
sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ),
|
sa.ForeignKeyConstraint(
|
||||||
|
['id'],
|
||||||
|
[f'{get_inv()}.action.id'],
|
||||||
|
),
|
||||||
sa.PrimaryKeyConstraint('id'),
|
sa.PrimaryKeyConstraint('id'),
|
||||||
schema=f'{get_inv()}'
|
schema=f'{get_inv()}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
op.drop_table('allocate', schema=f'{get_inv()}')
|
op.drop_table('allocate', schema=f'{get_inv()}')
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -7,8 +7,8 @@ from math import hypot
|
||||||
from typing import Iterator, List, Optional, TypeVar
|
from typing import Iterator, List, Optional, TypeVar
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
from ereuse_utils import getter, text
|
from ereuse_devicehub.ereuse_utils import getter, text
|
||||||
from ereuse_utils.nested_lookup import (
|
from ereuse_devicehub.ereuse_utils.nested_lookup import (
|
||||||
get_nested_dicts_with_key_containing_value,
|
get_nested_dicts_with_key_containing_value,
|
||||||
get_nested_dicts_with_key_value,
|
get_nested_dicts_with_key_value,
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,7 +5,7 @@ import struct
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from ereuse_utils import Dumpeable
|
from ereuse_devicehub.ereuse_utils import Dumpeable
|
||||||
|
|
||||||
|
|
||||||
class Severity(Enum):
|
class Severity(Enum):
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from flask import Response, jsonify, request
|
from flask import Response, jsonify, request
|
||||||
from teal.query import NestedQueryFlaskParser
|
|
||||||
from webargs.flaskparser import FlaskParser
|
from webargs.flaskparser import FlaskParser
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.query import NestedQueryFlaskParser
|
||||||
|
|
||||||
|
|
||||||
class SearchQueryParser(NestedQueryFlaskParser):
|
class SearchQueryParser(NestedQueryFlaskParser):
|
||||||
|
|
||||||
def parse_querystring(self, req, name, field):
|
def parse_querystring(self, req, name, field):
|
||||||
if name == 'search':
|
if name == 'search':
|
||||||
v = FlaskParser.parse_querystring(self, req, name, field)
|
v = FlaskParser.parse_querystring(self, req, name, field)
|
||||||
|
@ -15,29 +15,33 @@ class SearchQueryParser(NestedQueryFlaskParser):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
def things_response(items: List[Dict],
|
def things_response(
|
||||||
page: int = None,
|
items: List[Dict],
|
||||||
per_page: int = None,
|
page: int = None,
|
||||||
total: int = None,
|
per_page: int = None,
|
||||||
previous: int = None,
|
total: int = None,
|
||||||
next: int = None,
|
previous: int = None,
|
||||||
url: str = None,
|
next: int = None,
|
||||||
code: int = 200) -> Response:
|
url: str = None,
|
||||||
|
code: int = 200,
|
||||||
|
) -> Response:
|
||||||
"""Generates a Devicehub API list conformant response for multiple
|
"""Generates a Devicehub API list conformant response for multiple
|
||||||
things.
|
things.
|
||||||
"""
|
"""
|
||||||
response = jsonify({
|
response = jsonify(
|
||||||
'items': items,
|
{
|
||||||
# todo pagination should be in Header like github
|
'items': items,
|
||||||
# https://developer.github.com/v3/guides/traversing-with-pagination/
|
# todo pagination should be in Header like github
|
||||||
'pagination': {
|
# https://developer.github.com/v3/guides/traversing-with-pagination/
|
||||||
'page': page,
|
'pagination': {
|
||||||
'perPage': per_page,
|
'page': page,
|
||||||
'total': total,
|
'perPage': per_page,
|
||||||
'previous': previous,
|
'total': total,
|
||||||
'next': next
|
'previous': previous,
|
||||||
},
|
'next': next,
|
||||||
'url': url or request.path
|
},
|
||||||
})
|
'url': url or request.path,
|
||||||
|
}
|
||||||
|
)
|
||||||
response.status_code = code
|
response.status_code = code
|
||||||
return response
|
return response
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.action import schemas
|
from ereuse_devicehub.resources.action import schemas
|
||||||
from ereuse_devicehub.resources.action.views.views import (ActionView, AllocateView, DeallocateView,
|
from ereuse_devicehub.resources.action.views.views import (
|
||||||
LiveView)
|
ActionView,
|
||||||
|
AllocateView,
|
||||||
|
DeallocateView,
|
||||||
|
LiveView,
|
||||||
|
)
|
||||||
from ereuse_devicehub.resources.device.sync import Sync
|
from ereuse_devicehub.resources.device.sync import Sync
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class ActionDef(Resource):
|
class ActionDef(Resource):
|
||||||
|
@ -169,13 +172,32 @@ class SnapshotDef(ActionDef):
|
||||||
VIEW = None
|
VIEW = None
|
||||||
SCHEMA = schemas.Snapshot
|
SCHEMA = schemas.Snapshot
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None, cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
import_name=__name__.split('.')[0],
|
||||||
|
static_folder=None,
|
||||||
|
static_url_path=None,
|
||||||
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
url_prefix = '/{}'.format(ActionDef.resource)
|
url_prefix = '/{}'.format(ActionDef.resource)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
super().__init__(
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
self.sync = Sync()
|
self.sync = Sync()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,6 @@ from typing import Optional, Set, Union
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import inflection
|
import inflection
|
||||||
import teal.db
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
from dateutil.tz import tzutc
|
from dateutil.tz import tzutc
|
||||||
|
@ -50,19 +49,8 @@ from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
from sqlalchemy.orm import backref, relationship, validates
|
from sqlalchemy.orm import backref, relationship, validates
|
||||||
from sqlalchemy.orm.events import AttributeEvents as Events
|
from sqlalchemy.orm.events import AttributeEvents as Events
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.db import (
|
|
||||||
CASCADE_OWN,
|
|
||||||
INHERIT_COND,
|
|
||||||
POLYMORPHIC_ID,
|
|
||||||
POLYMORPHIC_ON,
|
|
||||||
URL,
|
|
||||||
StrictVersionType,
|
|
||||||
check_lower,
|
|
||||||
check_range,
|
|
||||||
)
|
|
||||||
from teal.enums import Currency
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
|
import ereuse_devicehub.teal.db
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.agent.models import Agent
|
from ereuse_devicehub.resources.agent.models import Agent
|
||||||
from ereuse_devicehub.resources.device.metrics import TradeMetrics
|
from ereuse_devicehub.resources.device.metrics import TradeMetrics
|
||||||
|
@ -94,6 +82,18 @@ from ereuse_devicehub.resources.enums import (
|
||||||
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
|
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import (
|
||||||
|
CASCADE_OWN,
|
||||||
|
INHERIT_COND,
|
||||||
|
POLYMORPHIC_ID,
|
||||||
|
POLYMORPHIC_ON,
|
||||||
|
URL,
|
||||||
|
StrictVersionType,
|
||||||
|
check_lower,
|
||||||
|
check_range,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.enums import Currency
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class JoinedTableMixin:
|
class JoinedTableMixin:
|
||||||
|
@ -125,7 +125,11 @@ class Action(Thing):
|
||||||
name.comment = """A name or title for the action. Used when searching
|
name.comment = """A name or title for the action. Used when searching
|
||||||
for actions.
|
for actions.
|
||||||
"""
|
"""
|
||||||
severity = Column(teal.db.IntEnum(Severity), default=Severity.Info, nullable=False)
|
severity = Column(
|
||||||
|
ereuse_devicehub.teal.db.IntEnum(Severity),
|
||||||
|
default=Severity.Info,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
severity.comment = Severity.__doc__
|
severity.comment = Severity.__doc__
|
||||||
closed = Column(Boolean, default=True, nullable=False)
|
closed = Column(Boolean, default=True, nullable=False)
|
||||||
closed.comment = """Whether the author has finished the action.
|
closed.comment = """Whether the author has finished the action.
|
||||||
|
@ -594,7 +598,11 @@ class Step(db.Model):
|
||||||
)
|
)
|
||||||
type = Column(Unicode(STR_SM_SIZE), nullable=False)
|
type = Column(Unicode(STR_SM_SIZE), nullable=False)
|
||||||
num = Column(SmallInteger, primary_key=True)
|
num = Column(SmallInteger, primary_key=True)
|
||||||
severity = Column(teal.db.IntEnum(Severity), default=Severity.Info, nullable=False)
|
severity = Column(
|
||||||
|
ereuse_devicehub.teal.db.IntEnum(Severity),
|
||||||
|
default=Severity.Info,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
start_time = Column(db.TIMESTAMP(timezone=True), nullable=False)
|
start_time = Column(db.TIMESTAMP(timezone=True), nullable=False)
|
||||||
start_time.comment = Action.start_time.comment
|
start_time.comment = Action.start_time.comment
|
||||||
end_time = Column(
|
end_time = Column(
|
||||||
|
|
|
@ -21,9 +21,6 @@ from marshmallow.fields import (
|
||||||
)
|
)
|
||||||
from marshmallow.validate import Length, OneOf, Range
|
from marshmallow.validate import Length, OneOf, Range
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.enums import Country, Currency, Subdivision
|
|
||||||
from teal.marshmallow import IP, URL, EnumField, SanitizedStr, Version
|
|
||||||
from teal.resource import Schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources import enums
|
from ereuse_devicehub.resources import enums
|
||||||
|
@ -48,6 +45,9 @@ from ereuse_devicehub.resources.tradedocument import schemas as s_document
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.user import schemas as s_user
|
from ereuse_devicehub.resources.user import schemas as s_user
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.enums import Country, Currency, Subdivision
|
||||||
|
from ereuse_devicehub.teal.marshmallow import IP, URL, EnumField, SanitizedStr, Version
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class Action(Thing):
|
class Action(Thing):
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from flask import g
|
from flask import g
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.inventory.models import Transfer
|
from ereuse_devicehub.inventory.models import Transfer
|
||||||
|
@ -13,6 +12,7 @@ from ereuse_devicehub.resources.action.models import (
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.lot.views import delete_from_trade
|
from ereuse_devicehub.resources.lot.views import delete_from_trade
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class TradeView:
|
class TradeView:
|
||||||
|
|
|
@ -4,13 +4,10 @@ from datetime import timedelta
|
||||||
from distutils.version import StrictVersion
|
from distutils.version import StrictVersion
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import ereuse_utils
|
import ereuse_devicehub.ereuse_utils
|
||||||
import jwt
|
import jwt
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from flask import g, request
|
from flask import g, request
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.query import things_response
|
from ereuse_devicehub.query import things_response
|
||||||
|
@ -35,6 +32,9 @@ from ereuse_devicehub.resources.action.views.snapshot import (
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
||||||
from ereuse_devicehub.resources.enums import Severity
|
from ereuse_devicehub.resources.enums import Severity
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
SUPPORTED_WORKBENCH = StrictVersion('11.0')
|
SUPPORTED_WORKBENCH = StrictVersion('11.0')
|
||||||
|
|
||||||
|
@ -203,7 +203,7 @@ def decode_snapshot(data):
|
||||||
data['data'],
|
data['data'],
|
||||||
app.config['JWT_PASS'],
|
app.config['JWT_PASS'],
|
||||||
algorithms="HS256",
|
algorithms="HS256",
|
||||||
json_encoder=ereuse_utils.JSONEncoder,
|
json_encoder=ereuse_devicehub.ereuse_utils.JSONEncoder,
|
||||||
)
|
)
|
||||||
except jwt.exceptions.InvalidSignatureError as err:
|
except jwt.exceptions.InvalidSignatureError as err:
|
||||||
txt = 'Invalid snapshot'
|
txt = 'Invalid snapshot'
|
||||||
|
|
|
@ -2,10 +2,10 @@ import json
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from boltons.typeutils import classproperty
|
from boltons.typeutils import classproperty
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.agent import models, schemas
|
from ereuse_devicehub.resources.agent import models, schemas
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class AgentDef(Resource):
|
class AgentDef(Resource):
|
||||||
|
@ -19,26 +19,40 @@ class OrganizationDef(AgentDef):
|
||||||
SCHEMA = schemas.Organization
|
SCHEMA = schemas.Organization
|
||||||
VIEW = None
|
VIEW = None
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None):
|
import_name=__name__.split('.')[0],
|
||||||
|
static_folder=None,
|
||||||
|
static_url_path=None,
|
||||||
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
):
|
||||||
cli_commands = ((self.create_org, 'add'),)
|
cli_commands = ((self.create_org, 'add'),)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
super().__init__(
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
||||||
@click.argument('name')
|
@click.argument('name')
|
||||||
@click.option('--tax_id', '-t')
|
@click.option('--tax_id', '-t')
|
||||||
@click.option('--country', '-c')
|
@click.option('--country', '-c')
|
||||||
def create_org(self, name: str, tax_id: str = None, country: str = None) -> dict:
|
def create_org(self, name: str, tax_id: str = None, country: str = None) -> dict:
|
||||||
"""Creates an organization."""
|
"""Creates an organization."""
|
||||||
org = models.Organization(**self.schema.load(
|
org = models.Organization(
|
||||||
{
|
**self.schema.load({'name': name, 'taxId': tax_id, 'country': country})
|
||||||
'name': name,
|
)
|
||||||
'taxId': tax_id,
|
|
||||||
'country': country
|
|
||||||
}
|
|
||||||
))
|
|
||||||
db.session.add(org)
|
db.session.add(org)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
o = self.schema.dump(org)
|
o = self.schema.dump(org)
|
||||||
|
|
|
@ -10,14 +10,19 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.ext.declarative import declared_attr
|
from sqlalchemy.ext.declarative import declared_attr
|
||||||
from sqlalchemy.orm import backref, relationship, validates
|
from sqlalchemy.orm import backref, relationship, validates
|
||||||
from sqlalchemy_utils import EmailType, PhoneNumberType
|
from sqlalchemy_utils import EmailType, PhoneNumberType
|
||||||
from teal import enums
|
|
||||||
from teal.db import INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON, check_lower
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.inventory import Inventory
|
from ereuse_devicehub.resources.inventory import Inventory
|
||||||
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
|
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal import enums
|
||||||
|
from ereuse_devicehub.teal.db import (
|
||||||
|
INHERIT_COND,
|
||||||
|
POLYMORPHIC_ID,
|
||||||
|
POLYMORPHIC_ON,
|
||||||
|
check_lower,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class JoinedTableMixin:
|
class JoinedTableMixin:
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
from marshmallow import fields as ma_fields, validate as ma_validate
|
from marshmallow import fields as ma_fields
|
||||||
|
from marshmallow import validate as ma_validate
|
||||||
from marshmallow.fields import Email
|
from marshmallow.fields import Email
|
||||||
from teal import enums
|
|
||||||
from teal.marshmallow import EnumField, Phone, SanitizedStr
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE, STR_SM_SIZE
|
from ereuse_devicehub.resources.models import STR_SIZE, STR_SM_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
|
from ereuse_devicehub.teal import enums
|
||||||
|
from ereuse_devicehub.teal.marshmallow import EnumField, Phone, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class Agent(Thing):
|
class Agent(Thing):
|
||||||
id = ma_fields.UUID(dump_only=True)
|
id = ma_fields.UUID(dump_only=True)
|
||||||
name = SanitizedStr(validate=ma_validate.Length(max=STR_SM_SIZE))
|
name = SanitizedStr(validate=ma_validate.Length(max=STR_SM_SIZE))
|
||||||
tax_id = SanitizedStr(lower=True,
|
tax_id = SanitizedStr(
|
||||||
validate=ma_validate.Length(max=STR_SM_SIZE),
|
lower=True, validate=ma_validate.Length(max=STR_SM_SIZE), data_key='taxId'
|
||||||
data_key='taxId')
|
)
|
||||||
country = EnumField(enums.Country)
|
country = EnumField(enums.Country)
|
||||||
telephone = Phone()
|
telephone = Phone()
|
||||||
email = Email()
|
email = Email()
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.deliverynote import schemas
|
from ereuse_devicehub.resources.deliverynote import schemas
|
||||||
from ereuse_devicehub.resources.deliverynote.views import DeliverynoteView
|
from ereuse_devicehub.resources.deliverynote.views import DeliverynoteView
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class DeliverynoteDef(Resource):
|
class DeliverynoteDef(Resource):
|
||||||
|
@ -12,15 +11,28 @@ class DeliverynoteDef(Resource):
|
||||||
AUTH = True
|
AUTH = True
|
||||||
ID_CONVERTER = Converters.uuid
|
ID_CONVERTER = Converters.uuid
|
||||||
|
|
||||||
def __init__(self, app,
|
def __init__(
|
||||||
import_name=__name__.split('.')[0],
|
self,
|
||||||
static_folder=None,
|
app,
|
||||||
static_url_path=None,
|
import_name=__name__.split('.')[0],
|
||||||
template_folder=None,
|
static_folder=None,
|
||||||
url_prefix=None,
|
static_url_path=None,
|
||||||
subdomain=None,
|
template_folder=None,
|
||||||
url_defaults=None,
|
url_prefix=None,
|
||||||
root_path=None,
|
subdomain=None,
|
||||||
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
url_defaults=None,
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
root_path=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
|
@ -5,35 +5,47 @@ from typing import Iterable
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
from flask import g
|
from flask import g
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from teal.db import check_range, IntEnum
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.enums import TransferState
|
from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import IntEnum, check_range
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class Deliverynote(Thing):
|
class Deliverynote(Thing):
|
||||||
id = db.Column(UUID(as_uuid=True), primary_key=True) # uuid is generated on init by default
|
id = db.Column(
|
||||||
|
UUID(as_uuid=True), primary_key=True
|
||||||
|
) # uuid is generated on init by default
|
||||||
document_id = db.Column(CIText(), nullable=False)
|
document_id = db.Column(CIText(), nullable=False)
|
||||||
creator_id = db.Column(UUID(as_uuid=True),
|
creator_id = db.Column(
|
||||||
db.ForeignKey(User.id),
|
UUID(as_uuid=True),
|
||||||
nullable=False,
|
db.ForeignKey(User.id),
|
||||||
default=lambda: g.user.id)
|
nullable=False,
|
||||||
|
default=lambda: g.user.id,
|
||||||
|
)
|
||||||
creator = db.relationship(User, primaryjoin=creator_id == User.id)
|
creator = db.relationship(User, primaryjoin=creator_id == User.id)
|
||||||
supplier_email = db.Column(CIText(),
|
supplier_email = db.Column(
|
||||||
db.ForeignKey(User.email),
|
CIText(),
|
||||||
nullable=False,
|
db.ForeignKey(User.email),
|
||||||
default=lambda: g.user.email)
|
nullable=False,
|
||||||
supplier = db.relationship(User, primaryjoin=lambda: Deliverynote.supplier_email == User.email)
|
default=lambda: g.user.email,
|
||||||
receiver_address = db.Column(CIText(),
|
)
|
||||||
db.ForeignKey(User.email),
|
supplier = db.relationship(
|
||||||
nullable=False,
|
User, primaryjoin=lambda: Deliverynote.supplier_email == User.email
|
||||||
default=lambda: g.user.email)
|
)
|
||||||
receiver = db.relationship(User, primaryjoin=lambda: Deliverynote.receiver_address == User.email)
|
receiver_address = db.Column(
|
||||||
|
CIText(),
|
||||||
|
db.ForeignKey(User.email),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: g.user.email,
|
||||||
|
)
|
||||||
|
receiver = db.relationship(
|
||||||
|
User, primaryjoin=lambda: Deliverynote.receiver_address == User.email
|
||||||
|
)
|
||||||
date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
date.comment = 'The date the DeliveryNote initiated'
|
date.comment = 'The date the DeliveryNote initiated'
|
||||||
amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0)
|
amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0)
|
||||||
|
@ -44,27 +56,37 @@ class Deliverynote(Thing):
|
||||||
expected_devices = db.Column(JSONB, nullable=False)
|
expected_devices = db.Column(JSONB, nullable=False)
|
||||||
# expected_devices = db.Column(db.ARRAY(JSONB, dimensions=1), nullable=False)
|
# expected_devices = db.Column(db.ARRAY(JSONB, dimensions=1), nullable=False)
|
||||||
transferred_devices = db.Column(db.ARRAY(db.Integer, dimensions=1), nullable=True)
|
transferred_devices = db.Column(db.ARRAY(db.Integer, dimensions=1), nullable=True)
|
||||||
transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False)
|
transfer_state = db.Column(
|
||||||
|
IntEnum(TransferState), default=TransferState.Initial, nullable=False
|
||||||
|
)
|
||||||
transfer_state.comment = TransferState.__doc__
|
transfer_state.comment = TransferState.__doc__
|
||||||
lot_id = db.Column(UUID(as_uuid=True),
|
lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
|
||||||
db.ForeignKey(Lot.id),
|
lot = db.relationship(
|
||||||
nullable=False)
|
Lot,
|
||||||
lot = db.relationship(Lot,
|
backref=db.backref('deliverynote', uselist=False, lazy=True),
|
||||||
backref=db.backref('deliverynote', uselist=False, lazy=True),
|
lazy=True,
|
||||||
lazy=True,
|
primaryjoin=Lot.id == lot_id,
|
||||||
primaryjoin=Lot.id == lot_id)
|
)
|
||||||
|
|
||||||
def __init__(self, document_id: str, amount: str, date,
|
def __init__(
|
||||||
supplier_email: str,
|
self,
|
||||||
expected_devices: Iterable,
|
document_id: str,
|
||||||
transfer_state: TransferState) -> None:
|
amount: str,
|
||||||
"""Initializes a delivery note
|
date,
|
||||||
"""
|
supplier_email: str,
|
||||||
super().__init__(id=uuid.uuid4(),
|
expected_devices: Iterable,
|
||||||
document_id=document_id, amount=amount, date=date,
|
transfer_state: TransferState,
|
||||||
supplier_email=supplier_email,
|
) -> None:
|
||||||
expected_devices=expected_devices,
|
"""Initializes a delivery note"""
|
||||||
transfer_state=transfer_state)
|
super().__init__(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
document_id=document_id,
|
||||||
|
amount=amount,
|
||||||
|
date=date,
|
||||||
|
supplier_email=supplier_email,
|
||||||
|
expected_devices=expected_devices,
|
||||||
|
transfer_state=transfer_state,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from teal.marshmallow import SanitizedStr, EnumField
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.deliverynote import models as m
|
from ereuse_devicehub.resources.deliverynote import models as m
|
||||||
|
@ -7,20 +6,30 @@ from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE
|
from ereuse_devicehub.resources.models import STR_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.user import schemas as s_user
|
from ereuse_devicehub.resources.user import schemas as s_user
|
||||||
|
from ereuse_devicehub.teal.marshmallow import EnumField, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class Deliverynote(Thing):
|
class Deliverynote(Thing):
|
||||||
id = f.UUID(dump_only=True)
|
id = f.UUID(dump_only=True)
|
||||||
document_id = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
|
document_id = SanitizedStr(
|
||||||
required=True, data_key='documentID')
|
validate=f.validate.Length(max=STR_SIZE), required=True, data_key='documentID'
|
||||||
|
)
|
||||||
creator = NestedOn(s_user.User, dump_only=True)
|
creator = NestedOn(s_user.User, dump_only=True)
|
||||||
supplier_email = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
|
supplier_email = SanitizedStr(
|
||||||
load_only=True, required=True, data_key='supplierEmail')
|
validate=f.validate.Length(max=STR_SIZE),
|
||||||
|
load_only=True,
|
||||||
|
required=True,
|
||||||
|
data_key='supplierEmail',
|
||||||
|
)
|
||||||
supplier = NestedOn(s_user.User, dump_only=True)
|
supplier = NestedOn(s_user.User, dump_only=True)
|
||||||
receiver = NestedOn(s_user.User, dump_only=True)
|
receiver = NestedOn(s_user.User, dump_only=True)
|
||||||
date = f.DateTime('iso', required=True)
|
date = f.DateTime('iso', required=True)
|
||||||
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
|
amount = f.Integer(
|
||||||
description=m.Deliverynote.amount.__doc__)
|
validate=f.validate.Range(min=0, max=100),
|
||||||
|
description=m.Deliverynote.amount.__doc__,
|
||||||
|
)
|
||||||
expected_devices = f.List(f.Dict, required=True, data_key='expectedDevices')
|
expected_devices = f.List(f.Dict, required=True, data_key='expectedDevices')
|
||||||
transferred_devices = f.List(f.Integer(), required=False, data_key='transferredDevices')
|
transferred_devices = f.List(
|
||||||
|
f.Integer(), required=False, data_key='transferredDevices'
|
||||||
|
)
|
||||||
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
||||||
|
|
|
@ -2,21 +2,22 @@ import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Response, request
|
from flask import Response, request
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.deliverynote.models import Deliverynote
|
from ereuse_devicehub.resources.deliverynote.models import Deliverynote
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class DeliverynoteView(View):
|
class DeliverynoteView(View):
|
||||||
|
|
||||||
def post(self):
|
def post(self):
|
||||||
# Create delivery note
|
# Create delivery note
|
||||||
dn = request.get_json()
|
dn = request.get_json()
|
||||||
dlvnote = Deliverynote(**dn)
|
dlvnote = Deliverynote(**dn)
|
||||||
# Create a lot
|
# Create a lot
|
||||||
lot_name = dlvnote.document_id + "_" + datetime.datetime.utcnow().strftime("%Y-%m-%d")
|
lot_name = (
|
||||||
|
dlvnote.document_id + "_" + datetime.datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
new_lot = Lot(name=lot_name)
|
new_lot = Lot(name=lot_name)
|
||||||
dlvnote.lot_id = new_lot.id
|
dlvnote.lot_id = new_lot.id
|
||||||
db.session.add(new_lot)
|
db.session.add(new_lot)
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.device import schemas
|
from ereuse_devicehub.resources.device import schemas
|
||||||
from ereuse_devicehub.resources.device.models import Manufacturer
|
from ereuse_devicehub.resources.device.models import Manufacturer
|
||||||
from ereuse_devicehub.resources.device.views import (
|
from ereuse_devicehub.resources.device.views import (
|
||||||
|
@ -9,6 +7,7 @@ from ereuse_devicehub.resources.device.views import (
|
||||||
DeviceView,
|
DeviceView,
|
||||||
ManufacturerView,
|
ManufacturerView,
|
||||||
)
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class DeviceDef(Resource):
|
class DeviceDef(Resource):
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
from teal.marshmallow import ValidationError
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class MismatchBetweenIds(ValidationError):
|
class MismatchBetweenIds(ValidationError):
|
||||||
def __init__(self, other_device_id: int, field: str, value: str):
|
def __init__(self, other_device_id: int, field: str, value: str):
|
||||||
message = 'The device {} has the same {} than this one ({}).'.format(other_device_id,
|
message = 'The device {} has the same {} than this one ({}).'.format(
|
||||||
field, value)
|
other_device_id, field, value
|
||||||
|
)
|
||||||
super().__init__(message, field_names=[field])
|
super().__init__(message, field_names=[field])
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,13 +16,15 @@ class NeedsId(ValidationError):
|
||||||
|
|
||||||
|
|
||||||
class DeviceIsInAnotherDevicehub(ValidationError):
|
class DeviceIsInAnotherDevicehub(ValidationError):
|
||||||
def __init__(self,
|
def __init__(
|
||||||
tag_id,
|
self,
|
||||||
message=None,
|
tag_id,
|
||||||
field_names=None,
|
message=None,
|
||||||
fields=None,
|
field_names=None,
|
||||||
data=None,
|
fields=None,
|
||||||
valid_data=None,
|
data=None,
|
||||||
**kwargs):
|
valid_data=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
message = message or 'Device {} is from another Devicehub.'.format(tag_id)
|
message = message or 'Device {} is from another Devicehub.'.format(tag_id)
|
||||||
super().__init__(message, field_names, fields, data, valid_data, **kwargs)
|
super().__init__(message, field_names, fields, data, valid_data, **kwargs)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import time
|
import time
|
||||||
|
@ -13,7 +14,6 @@ from typing import Dict, List, Set
|
||||||
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
from ereuse_utils.naming import HID_CONVERSION_DOC
|
|
||||||
from ereuseapi.methods import API
|
from ereuseapi.methods import API
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from flask import g, request, session
|
from flask import g, request, session
|
||||||
|
@ -37,21 +37,9 @@ from sqlalchemy.orm import ColumnProperty, backref, relationship, validates
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from sqlalchemy_utils import ColorType
|
from sqlalchemy_utils import ColorType
|
||||||
from stdnum import imei, meid
|
from stdnum import imei, meid
|
||||||
from teal.db import (
|
|
||||||
CASCADE_DEL,
|
|
||||||
POLYMORPHIC_ID,
|
|
||||||
POLYMORPHIC_ON,
|
|
||||||
URL,
|
|
||||||
IntEnum,
|
|
||||||
ResourceNotFound,
|
|
||||||
check_lower,
|
|
||||||
check_range,
|
|
||||||
)
|
|
||||||
from teal.enums import Layouts
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
from ereuse_devicehub.ereuse_utils.naming import HID_CONVERSION_DOC
|
||||||
from ereuse_devicehub.resources.device.metrics import Metrics
|
from ereuse_devicehub.resources.device.metrics import Metrics
|
||||||
from ereuse_devicehub.resources.enums import (
|
from ereuse_devicehub.resources.enums import (
|
||||||
BatteryTechnology,
|
BatteryTechnology,
|
||||||
|
@ -72,6 +60,21 @@ from ereuse_devicehub.resources.models import (
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
from ereuse_devicehub.resources.utils import hashcode
|
from ereuse_devicehub.resources.utils import hashcode
|
||||||
|
from ereuse_devicehub.teal.db import (
|
||||||
|
CASCADE_DEL,
|
||||||
|
POLYMORPHIC_ID,
|
||||||
|
POLYMORPHIC_ON,
|
||||||
|
URL,
|
||||||
|
IntEnum,
|
||||||
|
ResourceNotFound,
|
||||||
|
check_lower,
|
||||||
|
check_range,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.enums import Layouts
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_code(context):
|
def create_code(context):
|
||||||
|
@ -750,6 +753,28 @@ class Device(Thing):
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def get_lots_from_type(self, lot_type):
|
||||||
|
lots_type = {
|
||||||
|
'temporary': lambda x: x.is_temporary,
|
||||||
|
'incoming': lambda x: x.is_incoming,
|
||||||
|
'outgoing': lambda x: x.is_outgoing,
|
||||||
|
}
|
||||||
|
|
||||||
|
if lot_type not in lots_type:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
get_lots_type = lots_type[lot_type]
|
||||||
|
|
||||||
|
lots = self.lots
|
||||||
|
if not lots and self.binding:
|
||||||
|
lots = self.binding.device.lots
|
||||||
|
|
||||||
|
if lots:
|
||||||
|
lots = [lot.name for lot in lots if get_lots_type(lot)]
|
||||||
|
return ", ".join(sorted(lots))
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
def is_status(self, action):
|
def is_status(self, action):
|
||||||
from ereuse_devicehub.resources.device import states
|
from ereuse_devicehub.resources.device import states
|
||||||
|
|
||||||
|
@ -785,7 +810,7 @@ class Device(Thing):
|
||||||
def get_from_db(self):
|
def get_from_db(self):
|
||||||
if 'property_hid' in app.blueprints.keys():
|
if 'property_hid' in app.blueprints.keys():
|
||||||
try:
|
try:
|
||||||
from modules.device.utils import get_from_db
|
from ereuse_devicehub.modules.device.utils import get_from_db
|
||||||
|
|
||||||
return get_from_db(self)
|
return get_from_db(self)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
@ -804,13 +829,13 @@ class Device(Thing):
|
||||||
def set_hid(self):
|
def set_hid(self):
|
||||||
if 'property_hid' in app.blueprints.keys():
|
if 'property_hid' in app.blueprints.keys():
|
||||||
try:
|
try:
|
||||||
from modules.device.utils import set_hid
|
from ereuse_devicehub.modules.device.utils import set_hid
|
||||||
|
|
||||||
self.hid = set_hid(self)
|
self.hid = set_hid(self)
|
||||||
self.set_chid()
|
self.set_chid()
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception as err:
|
||||||
pass
|
logger.error(err)
|
||||||
|
|
||||||
self.hid = "{}-{}-{}-{}".format(
|
self.hid = "{}-{}-{}-{}".format(
|
||||||
self._clean_string(self.type),
|
self._clean_string(self.type),
|
||||||
|
@ -1251,6 +1276,13 @@ class Placeholder(Thing):
|
||||||
return 'Twin'
|
return 'Twin'
|
||||||
return 'Placeholder'
|
return 'Placeholder'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def documents(self):
|
||||||
|
docs = self.device.documents
|
||||||
|
if self.binding:
|
||||||
|
return docs.union(self.binding.documents)
|
||||||
|
return docs
|
||||||
|
|
||||||
|
|
||||||
class Computer(Device):
|
class Computer(Device):
|
||||||
"""A chassis with components inside that can be processed
|
"""A chassis with components inside that can be processed
|
||||||
|
|
|
@ -17,9 +17,6 @@ from marshmallow.fields import (
|
||||||
from marshmallow.validate import Length, OneOf, Range
|
from marshmallow.validate import Length, OneOf, Range
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from stdnum import imei, meid
|
from stdnum import imei, meid
|
||||||
from teal.enums import Layouts
|
|
||||||
from teal.marshmallow import URL, EnumField, SanitizedStr, ValidationError
|
|
||||||
from teal.resource import Schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources import enums
|
from ereuse_devicehub.resources import enums
|
||||||
|
@ -27,6 +24,14 @@ from ereuse_devicehub.resources.device import models as m
|
||||||
from ereuse_devicehub.resources.device import states
|
from ereuse_devicehub.resources.device import states
|
||||||
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
|
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing, UnitCodes
|
from ereuse_devicehub.resources.schemas import Thing, UnitCodes
|
||||||
|
from ereuse_devicehub.teal.enums import Layouts
|
||||||
|
from ereuse_devicehub.teal.marshmallow import (
|
||||||
|
URL,
|
||||||
|
EnumField,
|
||||||
|
SanitizedStr,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class Device(Thing):
|
class Device(Thing):
|
||||||
|
|
|
@ -8,8 +8,6 @@ from flask import g
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.action.models import Remove
|
from ereuse_devicehub.resources.action.models import Remove
|
||||||
|
@ -21,6 +19,8 @@ from ereuse_devicehub.resources.device.models import (
|
||||||
Placeholder,
|
Placeholder,
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
# DEVICES_ALLOW_DUPLICITY = [
|
# DEVICES_ALLOW_DUPLICITY = [
|
||||||
# 'RamModule',
|
# 'RamModule',
|
||||||
|
|
|
@ -12,10 +12,6 @@ from marshmallow import fields
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from marshmallow import validate as v
|
from marshmallow import validate as v
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal import query
|
|
||||||
from teal.cache import cache
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
@ -28,6 +24,11 @@ from ereuse_devicehub.resources.device.models import Computer, Device, Manufactu
|
||||||
from ereuse_devicehub.resources.device.search import DeviceSearch
|
from ereuse_devicehub.resources.device.search import DeviceSearch
|
||||||
from ereuse_devicehub.resources.lot.models import LotDeviceDescendants
|
from ereuse_devicehub.resources.lot.models import LotDeviceDescendants
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
|
from ereuse_devicehub.teal import query
|
||||||
|
from ereuse_devicehub.teal.cache import cache
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class OfType(f.Str):
|
class OfType(f.Str):
|
||||||
|
|
|
@ -37,8 +37,12 @@ class BaseDeviceRow(OrderedDict):
|
||||||
self['PHID'] = ''
|
self['PHID'] = ''
|
||||||
self['DHID'] = ''
|
self['DHID'] = ''
|
||||||
self['Type'] = ''
|
self['Type'] = ''
|
||||||
self['Placeholder Palet'] = ''
|
self['Temporary Lots'] = ''
|
||||||
|
self['Incoming Lots'] = ''
|
||||||
|
self['Outgoing Lots'] = ''
|
||||||
|
self['Placeholder Pallet'] = ''
|
||||||
self['Placeholder Id Supplier'] = ''
|
self['Placeholder Id Supplier'] = ''
|
||||||
|
self['Placeholder Id Internal'] = ''
|
||||||
self['Placeholder Info'] = ''
|
self['Placeholder Info'] = ''
|
||||||
self['Placeholder Components'] = ''
|
self['Placeholder Components'] = ''
|
||||||
self['Placeholder Type'] = ''
|
self['Placeholder Type'] = ''
|
||||||
|
@ -263,7 +267,7 @@ class BaseDeviceRow(OrderedDict):
|
||||||
|
|
||||||
|
|
||||||
class DeviceRow(BaseDeviceRow):
|
class DeviceRow(BaseDeviceRow):
|
||||||
def __init__(self, device: d.Device, document_ids: dict) -> None:
|
def __init__(self, device: d.Device, document_ids: dict) -> None: # noqa: C901
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.placeholder = device.binding or device.placeholder
|
self.placeholder = device.binding or device.placeholder
|
||||||
self.device = self.placeholder.binding or self.placeholder.device
|
self.device = self.placeholder.binding or self.placeholder.device
|
||||||
|
@ -504,8 +508,12 @@ class DeviceRow(BaseDeviceRow):
|
||||||
# Placeholder
|
# Placeholder
|
||||||
self['PHID'] = none2str(self.placeholder.phid)
|
self['PHID'] = none2str(self.placeholder.phid)
|
||||||
self['Type'] = none2str(self.device.is_abstract())
|
self['Type'] = none2str(self.device.is_abstract())
|
||||||
self['Placeholder Palet'] = none2str(self.placeholder.pallet)
|
self['Temporary Lots'] = none2str(self.device.get_lots_from_type('temporary'))
|
||||||
|
self['Incoming Lots'] = none2str(self.device.get_lots_from_type('incoming'))
|
||||||
|
self['Outgoing Lots'] = none2str(self.device.get_lots_from_type('outgoing'))
|
||||||
|
self['Placeholder Pallet'] = none2str(self.placeholder.pallet)
|
||||||
self['Placeholder Id Supplier'] = none2str(self.placeholder.id_device_supplier)
|
self['Placeholder Id Supplier'] = none2str(self.placeholder.id_device_supplier)
|
||||||
|
self['Placeholder Id Internal'] = none2str(self.placeholder.id_device_internal)
|
||||||
self['Placeholder Info'] = none2str(self.placeholder.info)
|
self['Placeholder Info'] = none2str(self.placeholder.info)
|
||||||
self['Placeholder Components'] = none2str(self.placeholder.components)
|
self['Placeholder Components'] = none2str(self.placeholder.components)
|
||||||
self['Placeholder Type'] = none2str(self.placeholder.device.type)
|
self['Placeholder Type'] = none2str(self.placeholder.device.type)
|
||||||
|
|
|
@ -11,14 +11,12 @@ from typing import Callable, Iterable, Tuple
|
||||||
import boltons
|
import boltons
|
||||||
import flask
|
import flask
|
||||||
import flask_weasyprint
|
import flask_weasyprint
|
||||||
import teal.marshmallow
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from flask import g, make_response, request
|
from flask import g, make_response, request
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from teal.cache import cache
|
|
||||||
from teal.resource import Resource, View
|
|
||||||
|
|
||||||
|
import ereuse_devicehub.teal.marshmallow
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.action import models as evs
|
from ereuse_devicehub.resources.action import models as evs
|
||||||
|
@ -37,6 +35,8 @@ from ereuse_devicehub.resources.hash_reports import ReportHash, insert_hash, ver
|
||||||
from ereuse_devicehub.resources.lot import LotView
|
from ereuse_devicehub.resources.lot import LotView
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
from ereuse_devicehub.resources.user.models import Session
|
from ereuse_devicehub.resources.user.models import Session
|
||||||
|
from ereuse_devicehub.teal.cache import cache
|
||||||
|
from ereuse_devicehub.teal.resource import Resource, View
|
||||||
|
|
||||||
|
|
||||||
class Format(enum.Enum):
|
class Format(enum.Enum):
|
||||||
|
@ -46,7 +46,7 @@ class Format(enum.Enum):
|
||||||
|
|
||||||
class DocumentView(DeviceView):
|
class DocumentView(DeviceView):
|
||||||
class FindArgs(DeviceView.FindArgs):
|
class FindArgs(DeviceView.FindArgs):
|
||||||
format = teal.marshmallow.EnumField(Format, missing=None)
|
format = ereuse_devicehub.teal.marshmallow.EnumField(Format, missing=None)
|
||||||
|
|
||||||
def get(self, id):
|
def get(self, id):
|
||||||
"""Get a collection of resources or a specific one.
|
"""Get a collection of resources or a specific one.
|
||||||
|
@ -71,7 +71,7 @@ class DocumentView(DeviceView):
|
||||||
|
|
||||||
if not ids and not id:
|
if not ids and not id:
|
||||||
msg = 'Document must be an ID or UUID.'
|
msg = 'Document must be an ID or UUID.'
|
||||||
raise teal.marshmallow.ValidationError(msg)
|
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
|
||||||
|
|
||||||
if id:
|
if id:
|
||||||
try:
|
try:
|
||||||
|
@ -81,7 +81,7 @@ class DocumentView(DeviceView):
|
||||||
ids.append(int(id))
|
ids.append(int(id))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
msg = 'Document must be an ID or UUID.'
|
msg = 'Document must be an ID or UUID.'
|
||||||
raise teal.marshmallow.ValidationError(msg)
|
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
|
||||||
else:
|
else:
|
||||||
query = devs.Device.query.filter(Device.id.in_(ids))
|
query = devs.Device.query.filter(Device.id.in_(ids))
|
||||||
else:
|
else:
|
||||||
|
@ -98,7 +98,7 @@ class DocumentView(DeviceView):
|
||||||
# try:
|
# try:
|
||||||
# id = int(id)
|
# id = int(id)
|
||||||
# except ValueError:
|
# except ValueError:
|
||||||
# raise teal.marshmallow.ValidationError('Document must be an ID or UUID.')
|
# raise ereuse_devicehub.teal.marshmallow.ValidationError('Document must be an ID or UUID.')
|
||||||
# else:
|
# else:
|
||||||
# query = devs.Device.query.filter_by(id=id)
|
# query = devs.Device.query.filter_by(id=id)
|
||||||
# else:
|
# else:
|
||||||
|
@ -138,7 +138,7 @@ class DocumentView(DeviceView):
|
||||||
url_pdf = boltons.urlutils.URL(flask.request.url)
|
url_pdf = boltons.urlutils.URL(flask.request.url)
|
||||||
url_pdf.query_params['format'] = 'PDF'
|
url_pdf.query_params['format'] = 'PDF'
|
||||||
params = {
|
params = {
|
||||||
'title': 'Erasure Certificate',
|
'title': 'Device Sanitization',
|
||||||
'erasures': tuple(erasures()),
|
'erasures': tuple(erasures()),
|
||||||
'url_pdf': url_pdf.to_text(),
|
'url_pdf': url_pdf.to_text(),
|
||||||
}
|
}
|
||||||
|
@ -280,7 +280,7 @@ class LotRow(OrderedDict):
|
||||||
self['Registered in'] = format(lot.created, '%c')
|
self['Registered in'] = format(lot.created, '%c')
|
||||||
try:
|
try:
|
||||||
self['Description'] = lot.description
|
self['Description'] = lot.description
|
||||||
except:
|
except Exception:
|
||||||
self['Description'] = ''
|
self['Description'] = ''
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,20 +1,19 @@
|
||||||
from flask import g
|
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
|
from flask import g
|
||||||
from sortedcontainers import SortedSet
|
from sortedcontainers import SortedSet
|
||||||
from sqlalchemy import BigInteger, Column, Sequence, Unicode, Boolean, ForeignKey
|
from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Sequence, Unicode
|
||||||
from sqlalchemy.ext.declarative import declared_attr
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.ext.declarative import declared_attr
|
||||||
from sqlalchemy.orm import backref
|
from sqlalchemy.orm import backref
|
||||||
from teal.db import CASCADE_OWN, URL
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
from ereuse_devicehub.resources.models import Thing, STR_SM_SIZE
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
|
||||||
|
|
||||||
|
|
||||||
_sorted_documents = {
|
_sorted_documents = {
|
||||||
'order_by': lambda: Document.created,
|
'order_by': lambda: Document.created,
|
||||||
'collection_class': SortedSet
|
'collection_class': SortedSet,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,11 +29,15 @@ class Document(Thing):
|
||||||
date.comment = """The date of document, some documents need to have one date
|
date.comment = """The date of document, some documents need to have one date
|
||||||
"""
|
"""
|
||||||
id_document = Column(CIText(), nullable=True)
|
id_document = Column(CIText(), nullable=True)
|
||||||
id_document.comment = """The id of one document like invoice so they can be linked."""
|
id_document.comment = (
|
||||||
owner_id = db.Column(UUID(as_uuid=True),
|
"""The id of one document like invoice so they can be linked."""
|
||||||
db.ForeignKey(User.id),
|
)
|
||||||
nullable=False,
|
owner_id = db.Column(
|
||||||
default=lambda: g.user.id)
|
UUID(as_uuid=True),
|
||||||
|
db.ForeignKey(User.id),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: g.user.id,
|
||||||
|
)
|
||||||
owner = db.relationship(User, primaryjoin=owner_id == User.id)
|
owner = db.relationship(User, primaryjoin=owner_id == User.id)
|
||||||
file_name = Column(db.CIText(), nullable=False)
|
file_name = Column(db.CIText(), nullable=False)
|
||||||
file_name.comment = """This is the name of the file when user up the document."""
|
file_name.comment = """This is the name of the file when user up the document."""
|
||||||
|
|
|
@ -1,34 +1,43 @@
|
||||||
from marshmallow.fields import DateTime, Integer, validate, Boolean, Float
|
|
||||||
from marshmallow import post_load
|
from marshmallow import post_load
|
||||||
|
from marshmallow.fields import Boolean, DateTime, Float, Integer, validate
|
||||||
from marshmallow.validate import Range
|
from marshmallow.validate import Range
|
||||||
from teal.marshmallow import SanitizedStr, URL
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
|
from ereuse_devicehub.resources.documents import models as m
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.documents import models as m
|
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class DataWipeDocument(Thing):
|
class DataWipeDocument(Thing):
|
||||||
__doc__ = m.DataWipeDocument.__doc__
|
__doc__ = m.DataWipeDocument.__doc__
|
||||||
id = Integer(description=m.DataWipeDocument.id.comment, dump_only=True)
|
id = Integer(description=m.DataWipeDocument.id.comment, dump_only=True)
|
||||||
url = URL(required= False, description=m.DataWipeDocument.url.comment)
|
url = URL(required=False, description=m.DataWipeDocument.url.comment)
|
||||||
success = Boolean(required=False, default=False, description=m.DataWipeDocument.success.comment)
|
success = Boolean(
|
||||||
|
required=False, default=False, description=m.DataWipeDocument.success.comment
|
||||||
|
)
|
||||||
software = SanitizedStr(description=m.DataWipeDocument.software.comment)
|
software = SanitizedStr(description=m.DataWipeDocument.software.comment)
|
||||||
date = DateTime(data_key='endTime',
|
date = DateTime(
|
||||||
required=False,
|
data_key='endTime', required=False, description=m.DataWipeDocument.date.comment
|
||||||
description=m.DataWipeDocument.date.comment)
|
)
|
||||||
id_document = SanitizedStr(data_key='documentId',
|
id_document = SanitizedStr(
|
||||||
required=False,
|
data_key='documentId',
|
||||||
default='',
|
required=False,
|
||||||
description=m.DataWipeDocument.id_document.comment)
|
default='',
|
||||||
file_name = SanitizedStr(data_key='filename',
|
description=m.DataWipeDocument.id_document.comment,
|
||||||
default='',
|
)
|
||||||
description=m.DataWipeDocument.file_name.comment,
|
file_name = SanitizedStr(
|
||||||
validate=validate.Length(max=100))
|
data_key='filename',
|
||||||
file_hash = SanitizedStr(data_key='hash',
|
default='',
|
||||||
default='',
|
description=m.DataWipeDocument.file_name.comment,
|
||||||
description=m.DataWipeDocument.file_hash.comment,
|
validate=validate.Length(max=100),
|
||||||
validate=validate.Length(max=64))
|
)
|
||||||
|
file_hash = SanitizedStr(
|
||||||
|
data_key='hash',
|
||||||
|
default='',
|
||||||
|
description=m.DataWipeDocument.file_hash.comment,
|
||||||
|
validate=validate.Length(max=64),
|
||||||
|
)
|
||||||
|
|
||||||
@post_load
|
@post_load
|
||||||
def get_trade_document(self, data):
|
def get_trade_document(self, data):
|
||||||
|
|
|
@ -1,28 +1,34 @@
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
from sqlalchemy import BigInteger, Column, Enum as DBEnum, ForeignKey
|
from sqlalchemy import BigInteger, Column
|
||||||
|
from sqlalchemy import Enum as DBEnum
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref, relationship
|
from sqlalchemy.orm import backref, relationship
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.db import CASCADE_OWN
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.device.models import Device
|
from ereuse_devicehub.resources.device.models import Device
|
||||||
from ereuse_devicehub.resources.enums import ImageMimeTypes, Orientation
|
from ereuse_devicehub.resources.enums import ImageMimeTypes, Orientation
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN
|
||||||
|
|
||||||
|
|
||||||
class ImageList(Thing):
|
class ImageList(Thing):
|
||||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||||
device_id = Column(BigInteger, ForeignKey(Device.id), nullable=False)
|
device_id = Column(BigInteger, ForeignKey(Device.id), nullable=False)
|
||||||
device = relationship(Device,
|
device = relationship(
|
||||||
primaryjoin=Device.id == device_id,
|
Device,
|
||||||
backref=backref('images',
|
primaryjoin=Device.id == device_id,
|
||||||
lazy=True,
|
backref=backref(
|
||||||
cascade=CASCADE_OWN,
|
'images',
|
||||||
order_by=lambda: ImageList.created,
|
lazy=True,
|
||||||
collection_class=OrderedSet))
|
cascade=CASCADE_OWN,
|
||||||
|
order_by=lambda: ImageList.created,
|
||||||
|
collection_class=OrderedSet,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Image(Thing):
|
class Image(Thing):
|
||||||
|
@ -32,12 +38,16 @@ class Image(Thing):
|
||||||
file_format = db.Column(DBEnum(ImageMimeTypes), nullable=False)
|
file_format = db.Column(DBEnum(ImageMimeTypes), nullable=False)
|
||||||
orientation = db.Column(DBEnum(Orientation), nullable=False)
|
orientation = db.Column(DBEnum(Orientation), nullable=False)
|
||||||
image_list_id = Column(UUID(as_uuid=True), ForeignKey(ImageList.id), nullable=False)
|
image_list_id = Column(UUID(as_uuid=True), ForeignKey(ImageList.id), nullable=False)
|
||||||
image_list = relationship(ImageList,
|
image_list = relationship(
|
||||||
primaryjoin=ImageList.id == image_list_id,
|
ImageList,
|
||||||
backref=backref('images',
|
primaryjoin=ImageList.id == image_list_id,
|
||||||
cascade=CASCADE_OWN,
|
backref=backref(
|
||||||
order_by=lambda: Image.created,
|
'images',
|
||||||
collection_class=OrderedSet))
|
cascade=CASCADE_OWN,
|
||||||
|
order_by=lambda: Image.created,
|
||||||
|
collection_class=OrderedSet,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# todo make an image Field that converts to/from image object
|
# todo make an image Field that converts to/from image object
|
||||||
# todo which metadata we get from Photobox?
|
# todo which metadata we get from Photobox?
|
||||||
|
|
|
@ -2,42 +2,61 @@ import uuid
|
||||||
|
|
||||||
import boltons.urlutils
|
import boltons.urlutils
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.resource import Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.inventory import schema
|
from ereuse_devicehub.resources.inventory import schema
|
||||||
from ereuse_devicehub.resources.inventory.model import Inventory
|
from ereuse_devicehub.resources.inventory.model import Inventory
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.resource import Resource
|
||||||
|
|
||||||
|
|
||||||
class InventoryDef(Resource):
|
class InventoryDef(Resource):
|
||||||
SCHEMA = schema.Inventory
|
SCHEMA = schema.Inventory
|
||||||
VIEW = None
|
VIEW = None
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None):
|
import_name=__name__.split('.')[0],
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
static_folder=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path)
|
static_url_path=None,
|
||||||
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_inventory_config(cls,
|
def set_inventory_config(
|
||||||
name: str = None,
|
cls,
|
||||||
org_name: str = None,
|
name: str = None,
|
||||||
org_id: str = None,
|
org_name: str = None,
|
||||||
tag_url: boltons.urlutils.URL = None,
|
org_id: str = None,
|
||||||
tag_token: uuid.UUID = None):
|
tag_url: boltons.urlutils.URL = None,
|
||||||
|
tag_token: uuid.UUID = None,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
inventory = Inventory.current
|
inventory = Inventory.current
|
||||||
except ResourceNotFound: # No inventory defined in db yet
|
except ResourceNotFound: # No inventory defined in db yet
|
||||||
inventory = Inventory(id=current_app.id,
|
inventory = Inventory(
|
||||||
name=name,
|
id=current_app.id, name=name, tag_provider=tag_url, tag_token=tag_token
|
||||||
tag_provider=tag_url,
|
)
|
||||||
tag_token=tag_token)
|
|
||||||
db.session.add(inventory)
|
db.session.add(inventory)
|
||||||
if org_name or org_id:
|
if org_name or org_id:
|
||||||
from ereuse_devicehub.resources.agent.models import Organization
|
from ereuse_devicehub.resources.agent.models import Organization
|
||||||
|
|
||||||
try:
|
try:
|
||||||
org = Organization.query.filter_by(tax_id=org_id, name=org_name).one()
|
org = Organization.query.filter_by(tax_id=org_id, name=org_name).one()
|
||||||
except ResourceNotFound:
|
except ResourceNotFound:
|
||||||
|
@ -54,12 +73,14 @@ class InventoryDef(Resource):
|
||||||
only access to this inventory.
|
only access to this inventory.
|
||||||
"""
|
"""
|
||||||
from ereuse_devicehub.resources.user.models import User, UserInventory
|
from ereuse_devicehub.resources.user.models import User, UserInventory
|
||||||
|
|
||||||
inv = Inventory.query.filter_by(id=current_app.id).one()
|
inv = Inventory.query.filter_by(id=current_app.id).one()
|
||||||
db.session.delete(inv)
|
db.session.delete(inv)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
# Remove users that end-up without any inventory
|
# Remove users that end-up without any inventory
|
||||||
# todo this should be done in a trigger / action
|
# todo this should be done in a trigger / action
|
||||||
users = User.query \
|
users = User.query.filter(
|
||||||
.filter(User.id.notin_(db.session.query(UserInventory.user_id).distinct()))
|
User.id.notin_(db.session.query(UserInventory.user_id).distinct())
|
||||||
|
)
|
||||||
for user in users:
|
for user in users:
|
||||||
db.session.delete(user)
|
db.session.delete(user)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import teal.marshmallow
|
import ereuse_devicehub.teal.marshmallow
|
||||||
from marshmallow import fields as mf
|
from marshmallow import fields as mf
|
||||||
|
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
|
@ -7,4 +7,6 @@ from ereuse_devicehub.resources.schemas import Thing
|
||||||
class Inventory(Thing):
|
class Inventory(Thing):
|
||||||
id = mf.String(dump_only=True)
|
id = mf.String(dump_only=True)
|
||||||
name = mf.String(dump_only=True)
|
name = mf.String(dump_only=True)
|
||||||
tag_provider = teal.marshmallow.URL(dump_only=True, data_key='tagProvider')
|
tag_provider = ereuse_devicehub.teal.marshmallow.URL(
|
||||||
|
dump_only=True, data_key='tagProvider'
|
||||||
|
)
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from teal.resource import Resource, View
|
|
||||||
|
from ereuse_devicehub.teal.resource import Resource, View
|
||||||
|
|
||||||
|
|
||||||
class LicenceView(View):
|
class LicenceView(View):
|
||||||
|
@ -23,18 +25,31 @@ class LicencesDef(Resource):
|
||||||
VIEW = None # We do not want to create default / documents endpoint
|
VIEW = None # We do not want to create default / documents endpoint
|
||||||
AUTH = False
|
AUTH = False
|
||||||
|
|
||||||
def __init__(self, app,
|
def __init__(
|
||||||
import_name=__name__,
|
self,
|
||||||
static_folder=None,
|
app,
|
||||||
static_url_path=None,
|
import_name=__name__,
|
||||||
template_folder=None,
|
static_folder=None,
|
||||||
url_prefix=None,
|
static_url_path=None,
|
||||||
subdomain=None,
|
template_folder=None,
|
||||||
url_defaults=None,
|
url_prefix=None,
|
||||||
root_path=None,
|
subdomain=None,
|
||||||
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
url_defaults=None,
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
root_path=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
||||||
get = {'GET'}
|
get = {'GET'}
|
||||||
d = {}
|
d = {}
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import pathlib
|
import pathlib
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.lot import schemas
|
from ereuse_devicehub.resources.lot import schemas
|
||||||
from ereuse_devicehub.resources.lot.views import LotBaseChildrenView, LotChildrenView, \
|
from ereuse_devicehub.resources.lot.views import (
|
||||||
LotDeviceView, LotView
|
LotBaseChildrenView,
|
||||||
|
LotChildrenView,
|
||||||
|
LotDeviceView,
|
||||||
|
LotView,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class LotDef(Resource):
|
class LotDef(Resource):
|
||||||
|
@ -15,24 +18,49 @@ class LotDef(Resource):
|
||||||
AUTH = True
|
AUTH = True
|
||||||
ID_CONVERTER = Converters.uuid
|
ID_CONVERTER = Converters.uuid
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None, cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
import_name=__name__.split('.')[0],
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
static_folder=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
static_url_path=None,
|
||||||
lot_children = LotChildrenView.as_view('lot-children', definition=self, auth=app.auth)
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
lot_children = LotChildrenView.as_view(
|
||||||
|
'lot-children', definition=self, auth=app.auth
|
||||||
|
)
|
||||||
if self.AUTH:
|
if self.AUTH:
|
||||||
lot_children = app.auth.requires_auth(lot_children)
|
lot_children = app.auth.requires_auth(lot_children)
|
||||||
self.add_url_rule('/<{}:{}>/children'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
self.add_url_rule(
|
||||||
view_func=lot_children,
|
'/<{}:{}>/children'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
||||||
methods={'POST', 'DELETE'})
|
view_func=lot_children,
|
||||||
|
methods={'POST', 'DELETE'},
|
||||||
|
)
|
||||||
lot_device = LotDeviceView.as_view('lot-device', definition=self, auth=app.auth)
|
lot_device = LotDeviceView.as_view('lot-device', definition=self, auth=app.auth)
|
||||||
if self.AUTH:
|
if self.AUTH:
|
||||||
lot_device = app.auth.requires_auth(lot_device)
|
lot_device = app.auth.requires_auth(lot_device)
|
||||||
self.add_url_rule('/<{}:{}>/devices'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
self.add_url_rule(
|
||||||
view_func=lot_device,
|
'/<{}:{}>/devices'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
||||||
methods={'POST', 'DELETE'})
|
view_func=lot_device,
|
||||||
|
methods={'POST', 'DELETE'},
|
||||||
|
)
|
||||||
|
|
||||||
def init_db(self, db: 'db.SQLAlchemy', exclude_schema=None):
|
def init_db(self, db: 'db.SQLAlchemy', exclude_schema=None):
|
||||||
# Create functions
|
# Create functions
|
||||||
|
|
|
@ -10,14 +10,14 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy_utils import LtreeType
|
from sqlalchemy_utils import LtreeType
|
||||||
from sqlalchemy_utils.types.ltree import LQUERY
|
from sqlalchemy_utils.types.ltree import LQUERY
|
||||||
from teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import create_view, db, exp, f
|
from ereuse_devicehub.db import create_view, db, exp, f
|
||||||
from ereuse_devicehub.resources.device.models import Component, Device
|
from ereuse_devicehub.resources.device.models import Component, Device
|
||||||
from ereuse_devicehub.resources.enums import TransferState
|
from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class Lot(Thing):
|
class Lot(Thing):
|
||||||
|
@ -125,7 +125,10 @@ class Lot(Thing):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_temporary(self):
|
def is_temporary(self):
|
||||||
return not bool(self.trade) and not bool(self.transfer)
|
trade = bool(self.trade)
|
||||||
|
transfer = bool(self.transfer)
|
||||||
|
owner = self.owner == g.user
|
||||||
|
return not trade and not transfer and owner
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_incoming(self):
|
def is_incoming(self):
|
||||||
|
@ -145,6 +148,19 @@ class Lot(Thing):
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_shared(self):
|
||||||
|
try:
|
||||||
|
self.shared
|
||||||
|
except Exception:
|
||||||
|
self.shared = ShareLot.query.filter_by(
|
||||||
|
lot_id=self.id, user_to=g.user
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if self.shared:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def descendantsq(cls, id):
|
def descendantsq(cls, id):
|
||||||
_id = UUIDLtree.convert(id)
|
_id = UUIDLtree.convert(id)
|
||||||
|
@ -397,3 +413,15 @@ class LotParent(db.Model):
|
||||||
.select_from(Path)
|
.select_from(Path)
|
||||||
.where(i > 0),
|
.where(i > 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShareLot(Thing):
|
||||||
|
id = db.Column(UUID(as_uuid=True), primary_key=True)
|
||||||
|
lot_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
|
||||||
|
lot = db.relationship(Lot, primaryjoin=lot_id == Lot.id)
|
||||||
|
user_to_id = db.Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
db.ForeignKey(User.id),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
user_to = db.relationship(User, primaryjoin=user_to_id == User.id)
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from teal.marshmallow import SanitizedStr, URL, EnumField
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
|
from ereuse_devicehub.resources.action import schemas as s_action
|
||||||
from ereuse_devicehub.resources.deliverynote import schemas as s_deliverynote
|
from ereuse_devicehub.resources.deliverynote import schemas as s_deliverynote
|
||||||
from ereuse_devicehub.resources.device import schemas as s_device
|
from ereuse_devicehub.resources.device import schemas as s_device
|
||||||
from ereuse_devicehub.resources.action import schemas as s_action
|
|
||||||
from ereuse_devicehub.resources.enums import TransferState
|
from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.lot import models as m
|
from ereuse_devicehub.resources.lot import models as m
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE
|
from ereuse_devicehub.resources.models import STR_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL, EnumField, SanitizedStr
|
||||||
|
|
||||||
TRADE_VALUES = (
|
TRADE_VALUES = (
|
||||||
'id',
|
'id',
|
||||||
|
@ -18,16 +17,11 @@ TRADE_VALUES = (
|
||||||
'user_from.id',
|
'user_from.id',
|
||||||
'user_to.id',
|
'user_to.id',
|
||||||
'user_to.code',
|
'user_to.code',
|
||||||
'user_from.code'
|
'user_from.code',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
DOCUMENTS_VALUES = (
|
DOCUMENTS_VALUES = ('id', 'file_name', 'total_weight', 'trading')
|
||||||
'id',
|
|
||||||
'file_name',
|
|
||||||
'total_weight',
|
|
||||||
'trading'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Old_Lot(Thing):
|
class Old_Lot(Thing):
|
||||||
|
@ -39,8 +33,9 @@ class Old_Lot(Thing):
|
||||||
children = NestedOn('Lot', many=True, dump_only=True)
|
children = NestedOn('Lot', many=True, dump_only=True)
|
||||||
parents = NestedOn('Lot', many=True, dump_only=True)
|
parents = NestedOn('Lot', many=True, dump_only=True)
|
||||||
url = URL(dump_only=True, description=m.Lot.url.__doc__)
|
url = URL(dump_only=True, description=m.Lot.url.__doc__)
|
||||||
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
|
amount = f.Integer(
|
||||||
description=m.Lot.amount.__doc__)
|
validate=f.validate.Range(min=0, max=100), description=m.Lot.amount.__doc__
|
||||||
|
)
|
||||||
# author_id = NestedOn(s_user.User,only_query='author_id')
|
# author_id = NestedOn(s_user.User,only_query='author_id')
|
||||||
owner_id = f.UUID(data_key='ownerID')
|
owner_id = f.UUID(data_key='ownerID')
|
||||||
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
||||||
|
@ -54,4 +49,6 @@ class Lot(Thing):
|
||||||
name = SanitizedStr(validate=f.validate.Length(max=STR_SIZE), required=True)
|
name = SanitizedStr(validate=f.validate.Length(max=STR_SIZE), required=True)
|
||||||
description = SanitizedStr(description=m.Lot.description.comment)
|
description = SanitizedStr(description=m.Lot.description.comment)
|
||||||
trade = f.Nested(s_action.Trade, dump_only=True, only=TRADE_VALUES)
|
trade = f.Nested(s_action.Trade, dump_only=True, only=TRADE_VALUES)
|
||||||
documents = f.Nested('TradeDocument', many=True, dump_only=True, only=DOCUMENTS_VALUES)
|
documents = f.Nested(
|
||||||
|
'TradeDocument', many=True, dump_only=True, only=DOCUMENTS_VALUES
|
||||||
|
)
|
||||||
|
|
|
@ -9,8 +9,6 @@ from marshmallow import Schema as MarshmallowSchema
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.marshmallow import EnumField
|
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.inventory.models import Transfer
|
from ereuse_devicehub.inventory.models import Transfer
|
||||||
|
@ -18,6 +16,8 @@ from ereuse_devicehub.query import things_response
|
||||||
from ereuse_devicehub.resources.action.models import Confirm, Revoke, Trade
|
from ereuse_devicehub.resources.action.models import Confirm, Revoke, Trade
|
||||||
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
||||||
from ereuse_devicehub.resources.lot.models import Lot, Path
|
from ereuse_devicehub.resources.lot.models import Lot, Path
|
||||||
|
from ereuse_devicehub.teal.marshmallow import EnumField
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class LotFormat(Enum):
|
class LotFormat(Enum):
|
||||||
|
@ -79,7 +79,7 @@ class LotView(View):
|
||||||
lot = Lot.query.filter_by(id=id).one() # type: Lot
|
lot = Lot.query.filter_by(id=id).one() # type: Lot
|
||||||
return self.schema.jsonify(lot, nested=2)
|
return self.schema.jsonify(lot, nested=2)
|
||||||
|
|
||||||
# @teal.cache.cache(datetime.timedelta(minutes=5))
|
# @ereuse_devicehub.teal.cache.cache(datetime.timedelta(minutes=5))
|
||||||
def find(self, args: dict):
|
def find(self, args: dict):
|
||||||
"""Gets lots.
|
"""Gets lots.
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from teal.resource import Resource
|
|
||||||
from ereuse_devicehub.resources.metric.schema import Metric
|
from ereuse_devicehub.resources.metric.schema import Metric
|
||||||
from ereuse_devicehub.resources.metric.views import MetricsView
|
from ereuse_devicehub.resources.metric.views import MetricsView
|
||||||
|
from ereuse_devicehub.teal.resource import Resource
|
||||||
|
|
||||||
|
|
||||||
class MetricDef(Resource):
|
class MetricDef(Resource):
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
from teal.resource import Schema
|
|
||||||
from marshmallow.fields import DateTime
|
from marshmallow.fields import DateTime
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class Metric(Schema):
|
class Metric(Schema):
|
||||||
"""
|
"""
|
||||||
This schema filter dates for search the metrics
|
This schema filter dates for search the metrics
|
||||||
"""
|
"""
|
||||||
start_time = DateTime(data_key='start_time', required=True,
|
|
||||||
description="Start date for search metrics")
|
start_time = DateTime(
|
||||||
end_time = DateTime(data_key='end_time', required=True,
|
data_key='start_time',
|
||||||
description="End date for search metrics")
|
required=True,
|
||||||
|
description="Start date for search metrics",
|
||||||
|
)
|
||||||
|
end_time = DateTime(
|
||||||
|
data_key='end_time', required=True, description="End date for search metrics"
|
||||||
|
)
|
||||||
|
|
|
@ -1,31 +1,38 @@
|
||||||
from flask import request, g, jsonify
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from teal.resource import View
|
|
||||||
|
from flask import g, jsonify, request
|
||||||
|
|
||||||
from ereuse_devicehub.resources.action import schemas
|
from ereuse_devicehub.resources.action import schemas
|
||||||
from ereuse_devicehub.resources.action.models import Allocate, Live, Action, ToRepair, ToPrepare
|
from ereuse_devicehub.resources.action.models import (
|
||||||
|
Action,
|
||||||
|
Allocate,
|
||||||
|
Live,
|
||||||
|
ToPrepare,
|
||||||
|
ToRepair,
|
||||||
|
)
|
||||||
from ereuse_devicehub.resources.device import models as m
|
from ereuse_devicehub.resources.device import models as m
|
||||||
from ereuse_devicehub.resources.metric.schema import Metric
|
from ereuse_devicehub.resources.metric.schema import Metric
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class MetricsView(View):
|
class MetricsView(View):
|
||||||
def find(self, args: dict):
|
def find(self, args: dict):
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
"allocateds": self.allocated(),
|
"allocateds": self.allocated(),
|
||||||
"live": self.live(),
|
"live": self.live(),
|
||||||
}
|
}
|
||||||
return jsonify(metrics)
|
return jsonify(metrics)
|
||||||
|
|
||||||
def allocated(self):
|
def allocated(self):
|
||||||
# TODO @cayop we need uncomment when the pr/83 is approved
|
# TODO @cayop we need uncomment when the pr/83 is approved
|
||||||
# return m.Device.query.filter(m.Device.allocated==True, owner==g.user).count()
|
# return m.Device.query.filter(m.Device.allocated==True, owner==g.user).count()
|
||||||
return m.Device.query.filter(m.Device.allocated==True).count()
|
return m.Device.query.filter(m.Device.allocated == True).count()
|
||||||
|
|
||||||
def live(self):
|
def live(self):
|
||||||
# TODO @cayop we need uncomment when the pr/83 is approved
|
# TODO @cayop we need uncomment when the pr/83 is approved
|
||||||
# devices = m.Device.query.filter(m.Device.allocated==True, owner==g.user)
|
# devices = m.Device.query.filter(m.Device.allocated==True, owner==g.user)
|
||||||
devices = m.Device.query.filter(m.Device.allocated==True)
|
devices = m.Device.query.filter(m.Device.allocated == True)
|
||||||
count = 0
|
count = 0
|
||||||
for dev in devices:
|
for dev in devices:
|
||||||
live = allocate = None
|
live = allocate = None
|
||||||
|
@ -41,4 +48,3 @@ class MetricsView(View):
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask_sqlalchemy import event
|
from flask_sqlalchemy import event
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
@ -16,18 +17,23 @@ class Thing(db.Model):
|
||||||
`schema.org's Thing class <https://schema.org/Thing>`_
|
`schema.org's Thing class <https://schema.org/Thing>`_
|
||||||
using only needed fields.
|
using only needed fields.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
updated = db.Column(db.TIMESTAMP(timezone=True),
|
updated = db.Column(
|
||||||
nullable=False,
|
db.TIMESTAMP(timezone=True),
|
||||||
index=True,
|
nullable=False,
|
||||||
server_default=db.text('CURRENT_TIMESTAMP'))
|
index=True,
|
||||||
|
server_default=db.text('CURRENT_TIMESTAMP'),
|
||||||
|
)
|
||||||
updated.comment = """The last time Devicehub recorded a change for
|
updated.comment = """The last time Devicehub recorded a change for
|
||||||
this thing.
|
this thing.
|
||||||
"""
|
"""
|
||||||
created = db.Column(db.TIMESTAMP(timezone=True),
|
created = db.Column(
|
||||||
nullable=False,
|
db.TIMESTAMP(timezone=True),
|
||||||
index=True,
|
nullable=False,
|
||||||
server_default=db.text('CURRENT_TIMESTAMP'))
|
index=True,
|
||||||
|
server_default=db.text('CURRENT_TIMESTAMP'),
|
||||||
|
)
|
||||||
created.comment = """When Devicehub created this."""
|
created.comment = """When Devicehub created this."""
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
|
@ -36,11 +42,15 @@ class Thing(db.Model):
|
||||||
self.created = kwargs.get('created', datetime.now(timezone.utc))
|
self.created = kwargs.get('created', datetime.now(timezone.utc))
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
db.session.delete(self)
|
||||||
|
|
||||||
|
|
||||||
def update_object_timestamp(mapper, connection, thing_obj):
|
def update_object_timestamp(mapper, connection, thing_obj):
|
||||||
""" This function update the stamptime of field updated """
|
"""This function update the stamptime of field updated"""
|
||||||
thing_obj.updated = datetime.now(timezone.utc)
|
thing_obj.updated = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def listener_reset_field_updated_in_actual_time(thing_obj):
|
def listener_reset_field_updated_in_actual_time(thing_obj):
|
||||||
""" This function launch a event than listen like a signal when some object is saved """
|
"""This function launch a event than listen like a signal when some object is saved"""
|
||||||
event.listen(thing_obj, 'before_update', update_object_timestamp, propagate=True)
|
event.listen(thing_obj, 'before_update', update_object_timestamp, propagate=True)
|
||||||
|
|
|
@ -4,10 +4,10 @@ from typing import Any
|
||||||
from marshmallow import post_load
|
from marshmallow import post_load
|
||||||
from marshmallow.fields import DateTime, List, String
|
from marshmallow.fields import DateTime, List, String
|
||||||
from marshmallow.schema import SchemaMeta
|
from marshmallow.schema import SchemaMeta
|
||||||
from teal.marshmallow import URL
|
|
||||||
from teal.resource import Schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources import models as m
|
from ereuse_devicehub.resources import models as m
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class UnitCodes(Enum):
|
class UnitCodes(Enum):
|
||||||
|
@ -38,8 +38,8 @@ class UnitCodes(Enum):
|
||||||
# Then the directive in our docs/config.py file reads these variables
|
# Then the directive in our docs/config.py file reads these variables
|
||||||
# generating the documentation.
|
# generating the documentation.
|
||||||
|
|
||||||
class Meta(type):
|
|
||||||
|
|
||||||
|
class Meta(type):
|
||||||
def __new__(cls, *args, **kw) -> Any:
|
def __new__(cls, *args, **kw) -> Any:
|
||||||
base_name = args[1][0].__name__
|
base_name = args[1][0].__name__
|
||||||
y = super().__new__(cls, *args, **kw)
|
y = super().__new__(cls, *args, **kw)
|
||||||
|
@ -47,7 +47,7 @@ class Meta(type):
|
||||||
return y
|
return y
|
||||||
|
|
||||||
|
|
||||||
SchemaMeta.__bases__ = Meta,
|
SchemaMeta.__bases__ = (Meta,)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -70,9 +70,7 @@ value.
|
||||||
|
|
||||||
class Thing(Schema):
|
class Thing(Schema):
|
||||||
type = String(description=_type_description)
|
type = String(description=_type_description)
|
||||||
same_as = List(URL(dump_only=True),
|
same_as = List(URL(dump_only=True), dump_only=True, data_key='sameAs')
|
||||||
dump_only=True,
|
|
||||||
data_key='sameAs')
|
|
||||||
updated = DateTime('iso', dump_only=True, description=m.Thing.updated.comment)
|
updated = DateTime('iso', dump_only=True, description=m.Thing.updated.comment)
|
||||||
created = DateTime('iso', dump_only=True, description=m.Thing.created.comment)
|
created = DateTime('iso', dump_only=True, description=m.Thing.created.comment)
|
||||||
|
|
||||||
|
|
|
@ -2,15 +2,19 @@ import csv
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
from click import argument, option
|
from click import argument, option
|
||||||
from ereuse_utils import cli
|
from ereuse_devicehub.ereuse_utils import cli
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
from teal.teal import Teal
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.device.definitions import DeviceDef
|
from ereuse_devicehub.resources.device.definitions import DeviceDef
|
||||||
from ereuse_devicehub.resources.tag import schema
|
from ereuse_devicehub.resources.tag import schema
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
from ereuse_devicehub.resources.tag.view import TagDeviceView, TagView, get_device_from_tag
|
from ereuse_devicehub.resources.tag.view import (
|
||||||
|
TagDeviceView,
|
||||||
|
TagView,
|
||||||
|
get_device_from_tag,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
from ereuse_devicehub.teal.teal import Teal
|
||||||
|
|
||||||
|
|
||||||
class TagDef(Resource):
|
class TagDef(Resource):
|
||||||
|
@ -25,48 +29,77 @@ class TagDef(Resource):
|
||||||
'By default set to the actual Devicehub.'
|
'By default set to the actual Devicehub.'
|
||||||
CLI_SCHEMA = schema.Tag(only=('id', 'provider', 'org', 'secondary'))
|
CLI_SCHEMA = schema.Tag(only=('id', 'provider', 'org', 'secondary'))
|
||||||
|
|
||||||
def __init__(self, app: Teal, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app: Teal,
|
||||||
root_path=None):
|
import_name=__name__.split('.')[0],
|
||||||
cli_commands = (
|
static_folder=None,
|
||||||
(self.create_tag, 'add'),
|
static_url_path=None,
|
||||||
(self.create_tags_csv, 'add-csv')
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
):
|
||||||
|
cli_commands = ((self.create_tag, 'add'), (self.create_tags_csv, 'add-csv'))
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
)
|
)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
|
||||||
|
|
||||||
# DeviceTagView URLs
|
# DeviceTagView URLs
|
||||||
device_view = TagDeviceView.as_view('tag-device-view', definition=self, auth=app.auth)
|
device_view = TagDeviceView.as_view(
|
||||||
|
'tag-device-view', definition=self, auth=app.auth
|
||||||
|
)
|
||||||
if self.AUTH:
|
if self.AUTH:
|
||||||
device_view = app.auth.requires_auth(device_view)
|
device_view = app.auth.requires_auth(device_view)
|
||||||
self.add_url_rule('/<{0.ID_CONVERTER.value}:{0.ID_NAME}>/device'.format(self),
|
self.add_url_rule(
|
||||||
view_func=device_view,
|
'/<{0.ID_CONVERTER.value}:{0.ID_NAME}>/device'.format(self),
|
||||||
methods={'GET'})
|
view_func=device_view,
|
||||||
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
|
methods={'GET'},
|
||||||
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
)
|
||||||
view_func=device_view,
|
self.add_url_rule(
|
||||||
methods={'PUT'})
|
'/<{0.ID_CONVERTER.value}:tag_id>/'.format(self)
|
||||||
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
|
+ 'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
||||||
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
view_func=device_view,
|
||||||
view_func=device_view,
|
methods={'PUT'},
|
||||||
methods={'DELETE'})
|
)
|
||||||
|
self.add_url_rule(
|
||||||
|
'/<{0.ID_CONVERTER.value}:tag_id>/'.format(self)
|
||||||
|
+ 'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
||||||
|
view_func=device_view,
|
||||||
|
methods={'DELETE'},
|
||||||
|
)
|
||||||
|
|
||||||
@option('-u', '--owner', help=OWNER_H)
|
@option('-u', '--owner', help=OWNER_H)
|
||||||
@option('-o', '--org', help=ORG_H)
|
@option('-o', '--org', help=ORG_H)
|
||||||
@option('-p', '--provider', help=PROV_H)
|
@option('-p', '--provider', help=PROV_H)
|
||||||
@option('-s', '--sec', help=Tag.secondary.comment)
|
@option('-s', '--sec', help=Tag.secondary.comment)
|
||||||
@argument('id')
|
@argument('id')
|
||||||
def create_tag(self,
|
def create_tag(
|
||||||
id: str,
|
self,
|
||||||
org: str = None,
|
id: str,
|
||||||
owner: str = None,
|
org: str = None,
|
||||||
sec: str = None,
|
owner: str = None,
|
||||||
provider: str = None):
|
sec: str = None,
|
||||||
|
provider: str = None,
|
||||||
|
):
|
||||||
"""Create a tag with the given ID."""
|
"""Create a tag with the given ID."""
|
||||||
db.session.add(Tag(**self.schema.load(
|
db.session.add(
|
||||||
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
|
Tag(
|
||||||
)))
|
**self.schema.load(
|
||||||
|
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@option('-u', '--owner', help=OWNER_H)
|
@option('-u', '--owner', help=OWNER_H)
|
||||||
|
@ -83,7 +116,17 @@ class TagDef(Resource):
|
||||||
"""
|
"""
|
||||||
with path.open() as f:
|
with path.open() as f:
|
||||||
for id, sec in csv.reader(f):
|
for id, sec in csv.reader(f):
|
||||||
db.session.add(Tag(**self.schema.load(
|
db.session.add(
|
||||||
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
|
Tag(
|
||||||
)))
|
**self.schema.load(
|
||||||
|
dict(
|
||||||
|
id=id,
|
||||||
|
owner=owner,
|
||||||
|
org=org,
|
||||||
|
secondary=sec,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -3,12 +3,9 @@ from typing import Set
|
||||||
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from flask import g
|
from flask import g
|
||||||
from sqlalchemy import BigInteger, Column, ForeignKey, UniqueConstraint, Sequence
|
from sqlalchemy import BigInteger, Column, ForeignKey, Sequence, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref, relationship, validates
|
from sqlalchemy.orm import backref, relationship, validates
|
||||||
from teal.db import DB_CASCADE_SET_NULL, Query, URL
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.agent.models import Organization
|
from ereuse_devicehub.resources.agent.models import Organization
|
||||||
|
@ -16,6 +13,9 @@ from ereuse_devicehub.resources.device.models import Device
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
from ereuse_devicehub.resources.utils import hashcode
|
from ereuse_devicehub.resources.utils import hashcode
|
||||||
|
from ereuse_devicehub.teal.db import DB_CASCADE_SET_NULL, URL, Query
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class Tags(Set['Tag']):
|
class Tags(Set['Tag']):
|
||||||
|
@ -26,51 +26,59 @@ class Tags(Set['Tag']):
|
||||||
return ', '.join(format(tag, format_spec) for tag in self).strip()
|
return ', '.join(format(tag, format_spec) for tag in self).strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(Thing):
|
class Tag(Thing):
|
||||||
internal_id = Column(BigInteger, Sequence('tag_internal_id_seq'), unique=True, nullable=False)
|
internal_id = Column(
|
||||||
|
BigInteger, Sequence('tag_internal_id_seq'), unique=True, nullable=False
|
||||||
|
)
|
||||||
internal_id.comment = """The identifier of the tag for this database. Used only
|
internal_id.comment = """The identifier of the tag for this database. Used only
|
||||||
internally for software; users should not use this.
|
internally for software; users should not use this.
|
||||||
"""
|
"""
|
||||||
id = Column(db.CIText(), primary_key=True)
|
id = Column(db.CIText(), primary_key=True)
|
||||||
id.comment = """The ID of the tag."""
|
id.comment = """The ID of the tag."""
|
||||||
owner_id = Column(UUID(as_uuid=True),
|
owner_id = Column(
|
||||||
ForeignKey(User.id),
|
UUID(as_uuid=True),
|
||||||
primary_key=True,
|
ForeignKey(User.id),
|
||||||
nullable=False,
|
primary_key=True,
|
||||||
default=lambda: g.user.id)
|
nullable=False,
|
||||||
|
default=lambda: g.user.id,
|
||||||
|
)
|
||||||
owner = relationship(User, primaryjoin=owner_id == User.id)
|
owner = relationship(User, primaryjoin=owner_id == User.id)
|
||||||
org_id = Column(UUID(as_uuid=True),
|
org_id = Column(
|
||||||
ForeignKey(Organization.id),
|
UUID(as_uuid=True),
|
||||||
# If we link with the Organization object this instance
|
ForeignKey(Organization.id),
|
||||||
# will be set as persistent and added to session
|
# If we link with the Organization object this instance
|
||||||
# which is something we don't want to enforce by default
|
# will be set as persistent and added to session
|
||||||
default=lambda: Organization.get_default_org_id())
|
# which is something we don't want to enforce by default
|
||||||
org = relationship(Organization,
|
default=lambda: Organization.get_default_org_id(),
|
||||||
backref=backref('tags', lazy=True),
|
)
|
||||||
primaryjoin=Organization.id == org_id,
|
org = relationship(
|
||||||
collection_class=set)
|
Organization,
|
||||||
|
backref=backref('tags', lazy=True),
|
||||||
|
primaryjoin=Organization.id == org_id,
|
||||||
|
collection_class=set,
|
||||||
|
)
|
||||||
"""The organization that issued the tag."""
|
"""The organization that issued the tag."""
|
||||||
provider = Column(URL())
|
provider = Column(URL())
|
||||||
provider.comment = """The tag provider URL. If None, the provider is
|
provider.comment = """The tag provider URL. If None, the provider is
|
||||||
this Devicehub.
|
this Devicehub.
|
||||||
"""
|
"""
|
||||||
device_id = Column(BigInteger,
|
device_id = Column(
|
||||||
# We don't want to delete the tag on device deletion, only set to null
|
BigInteger,
|
||||||
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL))
|
# We don't want to delete the tag on device deletion, only set to null
|
||||||
device = relationship(Device,
|
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL),
|
||||||
backref=backref('tags', lazy=True, collection_class=Tags),
|
)
|
||||||
primaryjoin=Device.id == device_id)
|
device = relationship(
|
||||||
|
Device,
|
||||||
|
backref=backref('tags', lazy=True, collection_class=Tags),
|
||||||
|
primaryjoin=Device.id == device_id,
|
||||||
|
)
|
||||||
"""The device linked to this tag."""
|
"""The device linked to this tag."""
|
||||||
secondary = Column(db.CIText(), index=True)
|
secondary = Column(db.CIText(), index=True)
|
||||||
secondary.comment = """A secondary identifier for this tag.
|
secondary.comment = """A secondary identifier for this tag.
|
||||||
It has the same constraints as the main one. Only needed in special cases.
|
It has the same constraints as the main one. Only needed in special cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (db.Index('device_id_index', device_id, postgresql_using='hash'),)
|
||||||
db.Index('device_id_index', device_id, postgresql_using='hash'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, id: str, **kwargs) -> None:
|
def __init__(self, id: str, **kwargs) -> None:
|
||||||
super().__init__(id=id, **kwargs)
|
super().__init__(id=id, **kwargs)
|
||||||
|
@ -99,13 +107,16 @@ class Tag(Thing):
|
||||||
@validates('provider')
|
@validates('provider')
|
||||||
def use_only_domain(self, _, url: URL):
|
def use_only_domain(self, _, url: URL):
|
||||||
if url.path:
|
if url.path:
|
||||||
raise ValidationError('Provider can only contain scheme and host',
|
raise ValidationError(
|
||||||
field_names=['provider'])
|
'Provider can only contain scheme and host', field_names=['provider']
|
||||||
|
)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(id, owner_id, name='one tag id per owner'),
|
UniqueConstraint(id, owner_id, name='one tag id per owner'),
|
||||||
UniqueConstraint(secondary, owner_id, name='one secondary tag per organization')
|
UniqueConstraint(
|
||||||
|
secondary, owner_id, name='one secondary tag per organization'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from marshmallow.fields import Boolean
|
from marshmallow.fields import Boolean
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.marshmallow import SanitizedStr, URL
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.agent.schemas import Organization
|
from ereuse_devicehub.resources.agent.schemas import Organization
|
||||||
|
@ -8,6 +7,7 @@ from ereuse_devicehub.resources.device.schemas import Device
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.tag import model as m
|
from ereuse_devicehub.resources.tag import model as m
|
||||||
from ereuse_devicehub.resources.user.schemas import User
|
from ereuse_devicehub.resources.user.schemas import User
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
def without_slash(x: str) -> bool:
|
def without_slash(x: str) -> bool:
|
||||||
|
@ -16,12 +16,10 @@ def without_slash(x: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
class Tag(Thing):
|
class Tag(Thing):
|
||||||
id = SanitizedStr(lower=True,
|
id = SanitizedStr(
|
||||||
description=m.Tag.id.comment,
|
lower=True, description=m.Tag.id.comment, validator=without_slash, required=True
|
||||||
validator=without_slash,
|
)
|
||||||
required=True)
|
provider = URL(description=m.Tag.provider.comment, validator=without_slash)
|
||||||
provider = URL(description=m.Tag.provider.comment,
|
|
||||||
validator=without_slash)
|
|
||||||
device = NestedOn(Device, dump_only=True)
|
device = NestedOn(Device, dump_only=True)
|
||||||
owner = NestedOn(User, only_query='id')
|
owner = NestedOn(User, only_query='id')
|
||||||
org = NestedOn(Organization, collection_class=OrderedSet, only_query='id')
|
org = NestedOn(Organization, collection_class=OrderedSet, only_query='id')
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
from flask import Response, current_app as app, g, redirect, request
|
from flask import Response
|
||||||
|
from flask import current_app as app
|
||||||
|
from flask import g, redirect, request
|
||||||
from flask_sqlalchemy import Pagination
|
from flask_sqlalchemy import Pagination
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import View, url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.query import things_response
|
from ereuse_devicehub.query import things_response
|
||||||
from ereuse_devicehub.resources.utils import hashcode
|
|
||||||
from ereuse_devicehub.resources.device.models import Device
|
from ereuse_devicehub.resources.device.models import Device
|
||||||
from ereuse_devicehub.resources.tag import Tag
|
from ereuse_devicehub.resources.tag import Tag
|
||||||
|
from ereuse_devicehub.resources.utils import hashcode
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import View, url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class TagView(View):
|
class TagView(View):
|
||||||
|
@ -34,13 +36,19 @@ class TagView(View):
|
||||||
|
|
||||||
@auth.Auth.requires_auth
|
@auth.Auth.requires_auth
|
||||||
def find(self, args: dict):
|
def find(self, args: dict):
|
||||||
tags = Tag.query.filter(Tag.is_printable_q()) \
|
tags = (
|
||||||
.filter_by(owner=g.user) \
|
Tag.query.filter(Tag.is_printable_q())
|
||||||
.order_by(Tag.created.desc()) \
|
.filter_by(owner=g.user)
|
||||||
.paginate(per_page=200) # type: Pagination
|
.order_by(Tag.created.desc())
|
||||||
|
.paginate(per_page=200)
|
||||||
|
) # type: Pagination
|
||||||
return things_response(
|
return things_response(
|
||||||
self.schema.dump(tags.items, many=True, nested=0),
|
self.schema.dump(tags.items, many=True, nested=0),
|
||||||
tags.page, tags.per_page, tags.total, tags.prev_num, tags.next_num
|
tags.page,
|
||||||
|
tags.per_page,
|
||||||
|
tags.total,
|
||||||
|
tags.prev_num,
|
||||||
|
tags.next_num,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_many_regular_tags(self, num: int):
|
def _create_many_regular_tags(self, num: int):
|
||||||
|
@ -48,7 +56,9 @@ class TagView(View):
|
||||||
tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id]
|
tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id]
|
||||||
db.session.add_all(tags)
|
db.session.add_all(tags)
|
||||||
db.session().final_flush()
|
db.session().final_flush()
|
||||||
response = things_response(self.schema.dump(tags, many=True, nested=1), code=201)
|
response = things_response(
|
||||||
|
self.schema.dump(tags, many=True, nested=1), code=201
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.tradedocument import schemas
|
from ereuse_devicehub.resources.tradedocument import schemas
|
||||||
from ereuse_devicehub.resources.tradedocument.views import TradeDocumentView
|
from ereuse_devicehub.resources.tradedocument.views import TradeDocumentView
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class TradeDocumentDef(Resource):
|
class TradeDocumentDef(Resource):
|
||||||
SCHEMA = schemas.TradeDocument
|
SCHEMA = schemas.TradeDocument
|
||||||
|
|
|
@ -7,12 +7,12 @@ from sortedcontainers import SortedSet
|
||||||
from sqlalchemy import BigInteger, Column, Sequence
|
from sqlalchemy import BigInteger, Column, Sequence
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref
|
from sqlalchemy.orm import backref
|
||||||
from teal.db import CASCADE_OWN, URL
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.enums import Severity
|
from ereuse_devicehub.resources.enums import Severity
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
|
||||||
|
|
||||||
_sorted_documents = {
|
_sorted_documents = {
|
||||||
'order_by': lambda: TradeDocument.created,
|
'order_by': lambda: TradeDocument.created,
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
from marshmallow.fields import DateTime, Integer, Float, validate
|
from marshmallow.fields import DateTime, Float, Integer, validate
|
||||||
from teal.marshmallow import SanitizedStr, URL
|
|
||||||
# from marshmallow import ValidationError, validates_schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.tradedocument import models as m
|
from ereuse_devicehub.resources.tradedocument import models as m
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
|
||||||
|
|
||||||
|
# from marshmallow import ValidationError, validates_schema
|
||||||
|
|
||||||
|
|
||||||
# from ereuse_devicehub.resources.lot import schemas as s_lot
|
# from ereuse_devicehub.resources.lot import schemas as s_lot
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,20 +15,28 @@ class TradeDocument(Thing):
|
||||||
__doc__ = m.TradeDocument.__doc__
|
__doc__ = m.TradeDocument.__doc__
|
||||||
id = Integer(description=m.TradeDocument.id.comment, dump_only=True)
|
id = Integer(description=m.TradeDocument.id.comment, dump_only=True)
|
||||||
date = DateTime(required=False, description=m.TradeDocument.date.comment)
|
date = DateTime(required=False, description=m.TradeDocument.date.comment)
|
||||||
id_document = SanitizedStr(data_key='documentId',
|
id_document = SanitizedStr(
|
||||||
default='',
|
data_key='documentId',
|
||||||
description=m.TradeDocument.id_document.comment)
|
default='',
|
||||||
description = SanitizedStr(default='',
|
description=m.TradeDocument.id_document.comment,
|
||||||
description=m.TradeDocument.description.comment,
|
)
|
||||||
validate=validate.Length(max=500))
|
description = SanitizedStr(
|
||||||
file_name = SanitizedStr(data_key='filename',
|
default='',
|
||||||
default='',
|
description=m.TradeDocument.description.comment,
|
||||||
description=m.TradeDocument.file_name.comment,
|
validate=validate.Length(max=500),
|
||||||
validate=validate.Length(max=100))
|
)
|
||||||
file_hash = SanitizedStr(data_key='hash',
|
file_name = SanitizedStr(
|
||||||
default='',
|
data_key='filename',
|
||||||
description=m.TradeDocument.file_hash.comment,
|
default='',
|
||||||
validate=validate.Length(max=64))
|
description=m.TradeDocument.file_name.comment,
|
||||||
|
validate=validate.Length(max=100),
|
||||||
|
)
|
||||||
|
file_hash = SanitizedStr(
|
||||||
|
data_key='hash',
|
||||||
|
default='',
|
||||||
|
description=m.TradeDocument.file_hash.comment,
|
||||||
|
validate=validate.Length(max=64),
|
||||||
|
)
|
||||||
url = URL(description=m.TradeDocument.url.comment)
|
url = URL(description=m.TradeDocument.url.comment)
|
||||||
lot = NestedOn('Lot', only_query='id', description=m.TradeDocument.lot.__doc__)
|
lot = NestedOn('Lot', only_query='id', description=m.TradeDocument.lot.__doc__)
|
||||||
trading = SanitizedStr(dump_only=True, description='')
|
trading = SanitizedStr(dump_only=True, description='')
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import current_app as app, request, g, Response
|
|
||||||
|
from flask import Response
|
||||||
|
from flask import current_app as app
|
||||||
|
from flask import g, request
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
|
||||||
from ereuse_devicehub.resources.action.models import ConfirmDocument
|
from ereuse_devicehub.resources.action.models import ConfirmDocument
|
||||||
from ereuse_devicehub.resources.hash_reports import ReportHash
|
from ereuse_devicehub.resources.hash_reports import ReportHash
|
||||||
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class TradeDocumentView(View):
|
class TradeDocumentView(View):
|
||||||
|
|
||||||
def one(self, id: str):
|
def one(self, id: str):
|
||||||
doc = TradeDocument.query.filter_by(id=id, owner=g.user).one()
|
doc = TradeDocument.query.filter_by(id=id, owner=g.user).one()
|
||||||
return self.schema.jsonify(doc)
|
return self.schema.jsonify(doc)
|
||||||
|
@ -33,10 +35,9 @@ class TradeDocumentView(View):
|
||||||
trade = doc.lot.trade
|
trade = doc.lot.trade
|
||||||
if trade:
|
if trade:
|
||||||
trade.documents.add(doc)
|
trade.documents.add(doc)
|
||||||
confirm = ConfirmDocument(action=trade,
|
confirm = ConfirmDocument(
|
||||||
user=g.user,
|
action=trade, user=g.user, devices=set(), documents={doc}
|
||||||
devices=set(),
|
)
|
||||||
documents={doc})
|
|
||||||
db.session.add(confirm)
|
db.session.add(confirm)
|
||||||
db.session.add(doc)
|
db.session.add(doc)
|
||||||
db.session().final_flush()
|
db.session().final_flush()
|
||||||
|
|
|
@ -2,12 +2,12 @@ from typing import Iterable
|
||||||
|
|
||||||
from click import argument, option
|
from click import argument, option
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.user import schemas
|
from ereuse_devicehub.resources.user import schemas
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
from ereuse_devicehub.resources.user.views import UserView, login, logout
|
from ereuse_devicehub.resources.user.views import UserView, login, logout
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class UserDef(Resource):
|
class UserDef(Resource):
|
||||||
|
@ -16,49 +16,88 @@ class UserDef(Resource):
|
||||||
ID_CONVERTER = Converters.uuid
|
ID_CONVERTER = Converters.uuid
|
||||||
AUTH = True
|
AUTH = True
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None, template_folder=None, url_prefix=None, subdomain=None,
|
self,
|
||||||
url_defaults=None, root_path=None):
|
app,
|
||||||
|
import_name=__name__.split('.')[0],
|
||||||
|
static_folder=None,
|
||||||
|
static_url_path=None,
|
||||||
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
):
|
||||||
cli_commands = ((self.create_user, 'add'),)
|
cli_commands = ((self.create_user, 'add'),)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
super().__init__(
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
self.add_url_rule('/login/', view_func=login, methods={'POST'})
|
self.add_url_rule('/login/', view_func=login, methods={'POST'})
|
||||||
logout_view = app.auth.requires_auth(logout)
|
logout_view = app.auth.requires_auth(logout)
|
||||||
self.add_url_rule('/logout/', view_func=logout_view, methods={'GET'})
|
self.add_url_rule('/logout/', view_func=logout_view, methods={'GET'})
|
||||||
|
|
||||||
@argument('email')
|
@argument('email')
|
||||||
@option('-i', '--inventory',
|
@option(
|
||||||
multiple=True,
|
'-i',
|
||||||
help='Inventories user has access to. By default this one.')
|
'--inventory',
|
||||||
@option('-a', '--agent',
|
multiple=True,
|
||||||
help='Create too an Individual agent representing this user, '
|
help='Inventories user has access to. By default this one.',
|
||||||
'and give a name to this individual.')
|
)
|
||||||
|
@option(
|
||||||
|
'-a',
|
||||||
|
'--agent',
|
||||||
|
help='Create too an Individual agent representing this user, '
|
||||||
|
'and give a name to this individual.',
|
||||||
|
)
|
||||||
@option('-c', '--country', help='The country of the agent (if --agent is set).')
|
@option('-c', '--country', help='The country of the agent (if --agent is set).')
|
||||||
@option('-t', '--telephone', help='The telephone of the agent (if --agent is set).')
|
@option('-t', '--telephone', help='The telephone of the agent (if --agent is set).')
|
||||||
@option('-t', '--tax-id', help='The tax id of the agent (if --agent is set).')
|
@option('-t', '--tax-id', help='The tax id of the agent (if --agent is set).')
|
||||||
@option('-p', '--password', prompt=True, hide_input=True, confirmation_prompt=True)
|
@option('-p', '--password', prompt=True, hide_input=True, confirmation_prompt=True)
|
||||||
def create_user(self, email: str,
|
def create_user(
|
||||||
password: str,
|
self,
|
||||||
inventory: Iterable[str] = tuple(),
|
email: str,
|
||||||
agent: str = None,
|
password: str,
|
||||||
country: str = None,
|
inventory: Iterable[str] = tuple(),
|
||||||
telephone: str = None,
|
agent: str = None,
|
||||||
tax_id: str = None) -> dict:
|
country: str = None,
|
||||||
|
telephone: str = None,
|
||||||
|
tax_id: str = None,
|
||||||
|
) -> dict:
|
||||||
"""Create an user.
|
"""Create an user.
|
||||||
|
|
||||||
If ``--agent`` is passed, it creates too an ``Individual``
|
If ``--agent`` is passed, it creates too an ``Individual``
|
||||||
agent that represents the user.
|
agent that represents the user.
|
||||||
"""
|
"""
|
||||||
from ereuse_devicehub.resources.agent.models import Individual
|
from ereuse_devicehub.resources.agent.models import Individual
|
||||||
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \
|
|
||||||
.load({'email': email, 'password': password})
|
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)).load(
|
||||||
|
{'email': email, 'password': password}
|
||||||
|
)
|
||||||
if inventory:
|
if inventory:
|
||||||
from ereuse_devicehub.resources.inventory import Inventory
|
from ereuse_devicehub.resources.inventory import Inventory
|
||||||
|
|
||||||
inventory = Inventory.query.filter(Inventory.id.in_(inventory))
|
inventory = Inventory.query.filter(Inventory.id.in_(inventory))
|
||||||
user = User(**u, inventories=inventory)
|
user = User(**u, inventories=inventory)
|
||||||
agent = Individual(**current_app.resources[Individual.t].schema.load(
|
agent = Individual(
|
||||||
dict(name=agent, email=email, country=country, telephone=telephone, taxId=tax_id)
|
**current_app.resources[Individual.t].schema.load(
|
||||||
))
|
dict(
|
||||||
|
name=agent,
|
||||||
|
email=email,
|
||||||
|
country=country,
|
||||||
|
telephone=telephone,
|
||||||
|
taxId=tax_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
user.individuals.add(agent)
|
user.individuals.add(agent)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -8,12 +8,12 @@ from flask_login import UserMixin
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, Sequence
|
from sqlalchemy import BigInteger, Boolean, Column, Sequence
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy_utils import EmailType, PasswordType
|
from sqlalchemy_utils import EmailType, PasswordType
|
||||||
from teal.db import CASCADE_OWN, URL, IntEnum
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.enums import SessionType
|
from ereuse_devicehub.resources.enums import SessionType
|
||||||
from ereuse_devicehub.resources.inventory.model import Inventory
|
from ereuse_devicehub.resources.inventory.model import Inventory
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE, Thing
|
from ereuse_devicehub.resources.models import STR_SIZE, Thing
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL, IntEnum
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, Thing):
|
class User(UserMixin, Thing):
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
from marshmallow import post_dump
|
from marshmallow import post_dump
|
||||||
from marshmallow.fields import Email, String, UUID
|
from marshmallow.fields import UUID, Email, String
|
||||||
from teal.marshmallow import SanitizedStr
|
|
||||||
|
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.agent.schemas import Individual
|
from ereuse_devicehub.resources.agent.schemas import Individual
|
||||||
from ereuse_devicehub.resources.inventory.schema import Inventory
|
from ereuse_devicehub.resources.inventory.schema import Inventory
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
|
from ereuse_devicehub.teal.marshmallow import SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class Session(Thing):
|
class Session(Thing):
|
||||||
|
@ -19,27 +19,33 @@ class User(Thing):
|
||||||
password = SanitizedStr(load_only=True, required=True)
|
password = SanitizedStr(load_only=True, required=True)
|
||||||
individuals = NestedOn(Individual, many=True, dump_only=True)
|
individuals = NestedOn(Individual, many=True, dump_only=True)
|
||||||
name = SanitizedStr()
|
name = SanitizedStr()
|
||||||
token = String(dump_only=True,
|
token = String(
|
||||||
description='Use this token in an Authorization header to access the app.'
|
dump_only=True,
|
||||||
'The token can change overtime.')
|
description='Use this token in an Authorization header to access the app.'
|
||||||
|
'The token can change overtime.',
|
||||||
|
)
|
||||||
inventories = NestedOn(Inventory, many=True, dump_only=True)
|
inventories = NestedOn(Inventory, many=True, dump_only=True)
|
||||||
code = String(dump_only=True, description='Code of inactive accounts')
|
code = String(dump_only=True, description='Code of inactive accounts')
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(
|
||||||
only=None,
|
self,
|
||||||
exclude=('token',),
|
only=None,
|
||||||
prefix='',
|
exclude=('token',),
|
||||||
many=False,
|
prefix='',
|
||||||
context=None,
|
many=False,
|
||||||
load_only=(),
|
context=None,
|
||||||
dump_only=(),
|
load_only=(),
|
||||||
partial=False):
|
dump_only=(),
|
||||||
|
partial=False,
|
||||||
|
):
|
||||||
"""Instantiates the User.
|
"""Instantiates the User.
|
||||||
|
|
||||||
By default we exclude token from both load/dump
|
By default we exclude token from both load/dump
|
||||||
so they are not taken / set in normal usage by mistake.
|
so they are not taken / set in normal usage by mistake.
|
||||||
"""
|
"""
|
||||||
super().__init__(only, exclude, prefix, many, context, load_only, dump_only, partial)
|
super().__init__(
|
||||||
|
only, exclude, prefix, many, context, load_only, dump_only, partial
|
||||||
|
)
|
||||||
|
|
||||||
@post_dump
|
@post_dump
|
||||||
def base64encode_token(self, data: dict):
|
def base64encode_token(self, data: dict):
|
||||||
|
|
|
@ -2,11 +2,11 @@ from uuid import UUID, uuid4
|
||||||
|
|
||||||
from flask import g, request
|
from flask import g, request
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
|
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class UserView(View):
|
class UserView(View):
|
||||||
|
@ -19,7 +19,9 @@ def login():
|
||||||
user_s = g.resource_def.SCHEMA(only=('email', 'password')) # type: UserS
|
user_s = g.resource_def.SCHEMA(only=('email', 'password')) # type: UserS
|
||||||
# noinspection PyArgumentList
|
# noinspection PyArgumentList
|
||||||
u = request.get_json(schema=user_s)
|
u = request.get_json(schema=user_s)
|
||||||
user = User.query.filter_by(email=u['email'], active=True, phantom=False).one_or_none()
|
user = User.query.filter_by(
|
||||||
|
email=u['email'], active=True, phantom=False
|
||||||
|
).one_or_none()
|
||||||
if user and user.password == u['password']:
|
if user and user.password == u['password']:
|
||||||
schema_with_token = g.resource_def.SCHEMA(exclude=set())
|
schema_with_token = g.resource_def.SCHEMA(exclude=set())
|
||||||
return schema_with_token.jsonify(user)
|
return schema_with_token.jsonify(user)
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import flask
|
|
||||||
import json
|
import json
|
||||||
import requests
|
|
||||||
import teal.marshmallow
|
|
||||||
|
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from flask import make_response, g
|
|
||||||
from flask.json import jsonify
|
|
||||||
from teal.resource import Resource, View
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.inventory.model import Inventory
|
import flask
|
||||||
|
import requests
|
||||||
|
from flask import g, make_response
|
||||||
|
from flask.json import jsonify
|
||||||
|
|
||||||
|
import ereuse_devicehub.teal.marshmallow
|
||||||
from ereuse_devicehub import __version__
|
from ereuse_devicehub import __version__
|
||||||
|
from ereuse_devicehub.resources.inventory.model import Inventory
|
||||||
|
from ereuse_devicehub.teal.resource import Resource, View
|
||||||
|
|
||||||
|
|
||||||
def get_tag_version(app):
|
def get_tag_version(app):
|
||||||
|
@ -29,6 +29,7 @@ def get_tag_version(app):
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class VersionView(View):
|
class VersionView(View):
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
"""Get version of DeviceHub and ereuse-tag."""
|
"""Get version of DeviceHub and ereuse-tag."""
|
||||||
|
@ -48,18 +49,31 @@ class VersionDef(Resource):
|
||||||
VIEW = None # We do not want to create default / documents endpoint
|
VIEW = None # We do not want to create default / documents endpoint
|
||||||
AUTH = False
|
AUTH = False
|
||||||
|
|
||||||
def __init__(self, app,
|
def __init__(
|
||||||
import_name=__name__,
|
self,
|
||||||
static_folder=None,
|
app,
|
||||||
static_url_path=None,
|
import_name=__name__,
|
||||||
template_folder=None,
|
static_folder=None,
|
||||||
url_prefix=None,
|
static_url_path=None,
|
||||||
subdomain=None,
|
template_folder=None,
|
||||||
url_defaults=None,
|
url_prefix=None,
|
||||||
root_path=None,
|
subdomain=None,
|
||||||
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
url_defaults=None,
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
root_path=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
||||||
d = {'devicehub': __version__, "ereuse_tag": "0.0.0"}
|
d = {'devicehub': __version__, "ereuse_tag": "0.0.0"}
|
||||||
get = {'GET'}
|
get = {'GET'}
|
||||||
|
|
|
@ -30,6 +30,10 @@ $(document).ready(() => {
|
||||||
|
|
||||||
;
|
;
|
||||||
select_shift(); // $('#selectLot').selectpicker();
|
select_shift(); // $('#selectLot').selectpicker();
|
||||||
|
|
||||||
|
$("#filter").on("change", () => {
|
||||||
|
$("#submit_filter").click();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class TableController {
|
class TableController {
|
||||||
|
@ -211,8 +215,8 @@ function removeLot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function select_shift() {
|
function select_shift() {
|
||||||
const chkboxes = $('.deviceSelect');
|
const chkboxes = $(".deviceSelect");
|
||||||
var lastChecked = null;
|
let lastChecked = null;
|
||||||
chkboxes.click(function (e) {
|
chkboxes.click(function (e) {
|
||||||
if (!lastChecked) {
|
if (!lastChecked) {
|
||||||
lastChecked = this;
|
lastChecked = this;
|
||||||
|
@ -324,17 +328,16 @@ function export_file(type_file) {
|
||||||
|
|
||||||
function export_actions_erasure(type_file) {
|
function export_actions_erasure(type_file) {
|
||||||
const actions = TableController.getSelectedDevices();
|
const actions = TableController.getSelectedDevices();
|
||||||
const actions_id = $.map(actions, (x) => $(x).attr("data-action-erasure")).join(",");
|
const actions_id = $.map(actions, x => $(x).attr("data-action-erasure")).join(",");
|
||||||
|
|
||||||
if (actions_id) {
|
if (actions_id) {
|
||||||
const url = `/inventory/export/${type_file}/?ids=${actions_id}`;
|
const url = "/inventory/export/".concat(type_file, "/?ids=").concat(actions_id);
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} else {
|
} else {
|
||||||
$("#exportAlertModal").click();
|
$("#exportAlertModal").click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class lotsSearcher {
|
class lotsSearcher {
|
||||||
static enable() {
|
static enable() {
|
||||||
if (this.lotsSearchElement) this.lotsSearchElement.disabled = false;
|
if (this.lotsSearchElement) this.lotsSearchElement.disabled = false;
|
||||||
|
@ -663,19 +666,14 @@ async function processSelectedDevices() {
|
||||||
|
|
||||||
return lot;
|
return lot;
|
||||||
});
|
});
|
||||||
|
|
||||||
listHTML.html("");
|
listHTML.html("");
|
||||||
const lot_temporary = lots.filter(lot => !lot.transfer && !lot.trade);
|
const lot_temporary = lots.filter(lot => !lot.transfer && !lot.trade);
|
||||||
appendMenu(lot_temporary, listHTML, templateLot, selectedDevices, actions, "Temporary");
|
appendMenu(lot_temporary, listHTML, templateLot, selectedDevices, actions, "Temporary");
|
||||||
|
|
||||||
const lot_incoming = lots.filter(lot => lot.transfer && lot.transfer == "Incoming");
|
const lot_incoming = lots.filter(lot => lot.transfer && lot.transfer == "Incoming");
|
||||||
appendMenu(lot_incoming, listHTML, templateLot, selectedDevices, actions, "Incoming");
|
appendMenu(lot_incoming, listHTML, templateLot, selectedDevices, actions, "Incoming");
|
||||||
|
|
||||||
const lot_outgoing = lots.filter(lot => lot.transfer && lot.transfer == "Outgoing");
|
const lot_outgoing = lots.filter(lot => lot.transfer && lot.transfer == "Outgoing");
|
||||||
appendMenu(lot_outgoing, listHTML, templateLot, selectedDevices, actions, "Outgoing");
|
appendMenu(lot_outgoing, listHTML, templateLot, selectedDevices, actions, "Outgoing");
|
||||||
|
|
||||||
lotsSearcher.enable();
|
lotsSearcher.enable();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
listHTML.html("<li style=\"color: red; text-align: center\">Error feching devices and lots<br>(see console for more details)</li>");
|
listHTML.html("<li style=\"color: red; text-align: center\">Error feching devices and lots<br>(see console for more details)</li>");
|
||||||
|
@ -689,6 +687,6 @@ function appendMenu(lots, listHTML, templateLot, selectedDevices, actions, title
|
||||||
lotsList.push(lots.filter(lot => lot.state == "false").sort((a, b) => a.name.localeCompare(b.name)));
|
lotsList.push(lots.filter(lot => lot.state == "false").sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
lotsList = lotsList.flat(); // flat array
|
lotsList = lotsList.flat(); // flat array
|
||||||
|
|
||||||
listHTML.append(`<li style="color: black; text-align: center">${ title }<hr /></li>`);
|
listHTML.append("<li style=\"color: black; text-align: center\">".concat(title, "<hr /></li>"));
|
||||||
lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions));
|
lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions));
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,9 @@ $(document).ready(() => {
|
||||||
};
|
};
|
||||||
select_shift();
|
select_shift();
|
||||||
// $('#selectLot').selectpicker();
|
// $('#selectLot').selectpicker();
|
||||||
|
$("#filter").on("change", () => {
|
||||||
|
$("#submit_filter").click();
|
||||||
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
class TableController {
|
class TableController {
|
||||||
|
|
0
ereuse_devicehub/teal/__init__.py
Normal file
0
ereuse_devicehub/teal/__init__.py
Normal file
93
ereuse_devicehub/teal/auth.py
Normal file
93
ereuse_devicehub/teal/auth.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import base64
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from flask import current_app, g, request
|
||||||
|
from werkzeug.datastructures import Authorization
|
||||||
|
from werkzeug.exceptions import Unauthorized
|
||||||
|
|
||||||
|
|
||||||
|
class Auth:
|
||||||
|
"""
|
||||||
|
Authentication handler for Teal.
|
||||||
|
|
||||||
|
To authenticate the user (perform login):
|
||||||
|
1. Set Resource.AUTH to True, or manually decorate the view with
|
||||||
|
@auth.requires_auth
|
||||||
|
2. Extend any subclass of this one (like TokenAuth).
|
||||||
|
3. Implement the authenticate method with the authentication logic.
|
||||||
|
For example, in TokenAuth here you get the user from the token.
|
||||||
|
5. Set in your teal the Auth class you have created so
|
||||||
|
teal can use it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
API_DOCS = {
|
||||||
|
'type': 'http',
|
||||||
|
'description:': 'HTTP Basic scheme',
|
||||||
|
'name': 'Authorization',
|
||||||
|
'in': 'header',
|
||||||
|
'scheme': 'basic',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_auth(cls, f: Callable):
|
||||||
|
"""
|
||||||
|
Decorate a view enforcing authentication (logged in user).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
auth = request.authorization
|
||||||
|
if not auth:
|
||||||
|
raise Unauthorized('Provide proper authorization credentials')
|
||||||
|
current_app.auth.perform_auth(auth)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
def perform_auth(self, auth: Authorization):
|
||||||
|
"""
|
||||||
|
Authenticate an user. This loads the user.
|
||||||
|
|
||||||
|
An exception (expected Unauthorized) is raised if
|
||||||
|
authentication failed.
|
||||||
|
"""
|
||||||
|
g.user = self.authenticate(auth.username, auth.password)
|
||||||
|
|
||||||
|
def authenticate(self, username: str, password: str) -> object:
|
||||||
|
"""
|
||||||
|
The authentication logic. The result of this method is
|
||||||
|
a user or a raised exception, like Werkzeug's Unauthorized,
|
||||||
|
if authentication failed.
|
||||||
|
|
||||||
|
:raise: Unauthorized Authentication failed.
|
||||||
|
:return: The user object.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenAuth(Auth):
|
||||||
|
API_DOCS = Auth.API_DOCS.copy()
|
||||||
|
API_DOCS['description'] = 'Basic scheme with token.'
|
||||||
|
|
||||||
|
def authenticate(self, token: str, *args, **kw) -> object:
|
||||||
|
"""
|
||||||
|
The result of this method is
|
||||||
|
a user or a raised exception if authentication failed.
|
||||||
|
|
||||||
|
:raise: Unauthorized Authentication failed.
|
||||||
|
:return The user object.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode(value: str):
|
||||||
|
"""Creates a suitable Token that can be sent to a client
|
||||||
|
and sent back.
|
||||||
|
"""
|
||||||
|
return base64.b64encode(str.encode(str(value) + ':')).decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode(value: str):
|
||||||
|
"""Decodes a token generated by ``encode``."""
|
||||||
|
return base64.b64decode(value.encode()).decode()[:-1]
|
28
ereuse_devicehub/teal/cache.py
Normal file
28
ereuse_devicehub/teal/cache.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import datetime
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import 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))
|
||||||
|
r.expires = datetime.datetime.now(datetime.timezone.utc) + expires
|
||||||
|
r.cache_control.public = True
|
||||||
|
return r
|
||||||
|
|
||||||
|
return cache_func
|
||||||
|
|
||||||
|
return cache_decorator
|
13
ereuse_devicehub/teal/cli.py
Normal file
13
ereuse_devicehub/teal/cli.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
|
|
||||||
|
class TealCliRunner(FlaskCliRunner):
|
||||||
|
"""The same as FlaskCliRunner but with invoke's
|
||||||
|
'catch_exceptions' as False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def invoke(self, *args, cli=None, **kwargs):
|
||||||
|
kwargs.setdefault('catch_exceptions', False)
|
||||||
|
r = super().invoke(cli, args, **kwargs)
|
||||||
|
assert r.exit_code == 0, 'CLI code {}: {}'.format(r.exit_code, r.output)
|
||||||
|
return r
|
181
ereuse_devicehub/teal/client.py
Normal file
181
ereuse_devicehub/teal/client.py
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
from typing import Any, Iterable, Tuple, Type, Union
|
||||||
|
|
||||||
|
from boltons.urlutils import URL
|
||||||
|
from ereuse_devicehub.ereuse_utils.test import JSON
|
||||||
|
from ereuse_devicehub.ereuse_utils.test import Client as EreuseUtilsClient
|
||||||
|
from ereuse_devicehub.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])
|
70
ereuse_devicehub/teal/config.py
Normal file
70
ereuse_devicehub/teal/config.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
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()
|
||||||
|
"""
|
||||||
|
A list of resource definitions to load.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = None
|
||||||
|
"""
|
||||||
|
The access to the main Database.
|
||||||
|
"""
|
||||||
|
SQLALCHEMY_BINDS = {}
|
||||||
|
"""
|
||||||
|
Optional extra databases. See `here <http://flask-sqlalchemy.pocoo.org
|
||||||
|
/2.3/binds/#referring-to-binds>`_ how bind your models to different
|
||||||
|
databases.
|
||||||
|
"""
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
"""
|
||||||
|
Disables flask-sqlalchemy notification system.
|
||||||
|
Save resources and hides a warning by flask-sqlalchemy itself.
|
||||||
|
|
||||||
|
See `this answer in Stackoverflow for more info
|
||||||
|
<https://stackoverflow.com/a/33790196>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
API_DOC_CONFIG_TITLE = 'Teal'
|
||||||
|
API_DOC_CONFIG_VERSION = '0.1'
|
||||||
|
"""
|
||||||
|
Configuration options for the api docs. They are the parameters
|
||||||
|
passed to `apispec <http://apispec.readthedocs.io/en/
|
||||||
|
latest/api_core.html#apispec.APISpec>`_. Prefix the configuration
|
||||||
|
names with ``API_DOC_CONFIG_``.
|
||||||
|
"""
|
||||||
|
API_DOC_CLASS_DISCRIMINATOR = None
|
||||||
|
"""
|
||||||
|
Configuration options for the api docs class definitions.
|
||||||
|
|
||||||
|
You can pass any `schema definition <https://github.com/OAI/
|
||||||
|
OpenAPI-Specification/blob/master/versions/2.0.md#schemaObject>`_
|
||||||
|
prefiex by ``API_DOC_CLASS_`` like in the example above.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CORS_ORIGINS = '*'
|
||||||
|
CORS_EXPOSE_HEADERS = 'Authorization'
|
||||||
|
CORS_ALLOW_HEADERS = 'Content-Type', 'Authorization'
|
||||||
|
"""
|
||||||
|
Configuration for CORS. See the options you can pass by in `Flask-Cors
|
||||||
|
<https://flask-cors.corydolphin.com/en/latest/api.html#extension>`_,
|
||||||
|
exactly in **Parameters**, like the ones above.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""
|
||||||
|
:param db: Optional. Set the ``SQLALCHEMY_DATABASE_URI`` param.
|
||||||
|
"""
|
||||||
|
for r in self.RESOURCE_DEFINITIONS:
|
||||||
|
assert issubclass(
|
||||||
|
r, Resource
|
||||||
|
), '{0!r} is not a subclass of Resource'.format(r)
|
382
ereuse_devicehub/teal/db.py
Normal file
382
ereuse_devicehub/teal/db.py
Normal file
|
@ -0,0 +1,382 @@
|
||||||
|
import enum
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from distutils.version import StrictVersion
|
||||||
|
from typing import Any, Type, Union
|
||||||
|
|
||||||
|
from boltons.typeutils import classproperty
|
||||||
|
from boltons.urlutils import URL as BoltonsUrl
|
||||||
|
from ereuse_devicehub.ereuse_utils import if_none_return_none
|
||||||
|
from flask_sqlalchemy import BaseQuery
|
||||||
|
from flask_sqlalchemy import Model as _Model
|
||||||
|
from flask_sqlalchemy import SignallingSession
|
||||||
|
from flask_sqlalchemy import SQLAlchemy as FlaskSQLAlchemy
|
||||||
|
from sqlalchemy import CheckConstraint, SmallInteger, cast, event, types
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, INET
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
|
||||||
|
from sqlalchemy_utils import Ltree
|
||||||
|
from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceNotFound(NotFound):
|
||||||
|
# todo show id
|
||||||
|
def __init__(self, resource: str) -> None:
|
||||||
|
super().__init__('The {} doesn\'t exist.'.format(resource))
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleResourcesFound(UnprocessableEntity):
|
||||||
|
# todo show id
|
||||||
|
def __init__(self, resource: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
'Expected only one {} but multiple where found'.format(resource)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
POLYMORPHIC_ID = 'polymorphic_identity'
|
||||||
|
POLYMORPHIC_ON = 'polymorphic_on'
|
||||||
|
INHERIT_COND = 'inherit_condition'
|
||||||
|
DEFAULT_CASCADE = 'save-update, merge'
|
||||||
|
CASCADE_DEL = '{}, delete'.format(DEFAULT_CASCADE)
|
||||||
|
CASCADE_OWN = '{}, delete-orphan'.format(CASCADE_DEL)
|
||||||
|
DB_CASCADE_SET_NULL = 'SET NULL'
|
||||||
|
|
||||||
|
|
||||||
|
class Query(BaseQuery):
|
||||||
|
def one(self):
|
||||||
|
try:
|
||||||
|
return super().one()
|
||||||
|
except NoResultFound:
|
||||||
|
raise ResourceNotFound(self._entities[0]._label_name)
|
||||||
|
except MultipleResultsFound:
|
||||||
|
raise MultipleResourcesFound(self._entities[0]._label_name)
|
||||||
|
|
||||||
|
|
||||||
|
class Model(_Model):
|
||||||
|
# Just provide typing
|
||||||
|
query_class = Query # type: Type[Query]
|
||||||
|
query = None # type: Query
|
||||||
|
|
||||||
|
@classproperty
|
||||||
|
def t(cls):
|
||||||
|
return cls.__name__
|
||||||
|
|
||||||
|
|
||||||
|
class Session(SignallingSession):
|
||||||
|
"""A SQLAlchemy session that raises better exceptions."""
|
||||||
|
|
||||||
|
def _flush(self, objects=None):
|
||||||
|
try:
|
||||||
|
super()._flush(objects)
|
||||||
|
except IntegrityError as e:
|
||||||
|
raise DBError(e) # This creates a suitable subclass
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaSession(Session):
|
||||||
|
"""Session that is configured to use a PostgreSQL's Schema.
|
||||||
|
|
||||||
|
Idea from `here <https://stackoverflow.com/a/9299021>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db, autocommit=False, autoflush=True, **options):
|
||||||
|
super().__init__(db, autocommit, autoflush, **options)
|
||||||
|
self.execute('SET search_path TO {}, public'.format(self.app.schema))
|
||||||
|
|
||||||
|
|
||||||
|
class StrictVersionType(types.TypeDecorator):
|
||||||
|
"""StrictVersion support for SQLAlchemy as Unicode.
|
||||||
|
|
||||||
|
Idea `from official documentation <http://docs.sqlalchemy.org/en/
|
||||||
|
latest/core/custom_types.html#augmenting-existing-types>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
impl = types.Unicode
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return StrictVersion(value)
|
||||||
|
|
||||||
|
|
||||||
|
class URL(types.TypeDecorator):
|
||||||
|
"""bolton's URL support for SQLAlchemy as Unicode."""
|
||||||
|
|
||||||
|
impl = types.Unicode
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value: BoltonsUrl, dialect):
|
||||||
|
return value.to_text()
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return BoltonsUrl(value)
|
||||||
|
|
||||||
|
|
||||||
|
class IP(types.TypeDecorator):
|
||||||
|
"""ipaddress support for SQLAlchemy as PSQL INET."""
|
||||||
|
|
||||||
|
impl = INET
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return ipaddress.ip_address(value)
|
||||||
|
|
||||||
|
|
||||||
|
class IntEnum(types.TypeDecorator):
|
||||||
|
"""SmallInteger -- IntEnum"""
|
||||||
|
|
||||||
|
impl = SmallInteger
|
||||||
|
|
||||||
|
def __init__(self, enumeration: Type[enum.IntEnum], *args, **kwargs):
|
||||||
|
self.enum = enumeration
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
assert isinstance(value, self.enum), 'Value should be instance of {}'.format(
|
||||||
|
self.enum
|
||||||
|
)
|
||||||
|
return value.value
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return self.enum(value)
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDLtree(Ltree):
|
||||||
|
"""This Ltree only wants UUIDs as paths elements."""
|
||||||
|
|
||||||
|
def __init__(self, path_or_ltree: Union[Ltree, uuid.UUID]):
|
||||||
|
"""
|
||||||
|
Creates a new Ltree. If the passed-in value is an UUID,
|
||||||
|
it automatically generates a suitable string for Ltree.
|
||||||
|
"""
|
||||||
|
if not isinstance(path_or_ltree, Ltree):
|
||||||
|
if isinstance(path_or_ltree, uuid.UUID):
|
||||||
|
path_or_ltree = self.convert(path_or_ltree)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
'Ltree does not accept {}'.format(path_or_ltree.__class__)
|
||||||
|
)
|
||||||
|
super().__init__(path_or_ltree)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def convert(id: uuid.UUID) -> str:
|
||||||
|
"""Transforms an uuid to a ready-to-ltree str representation."""
|
||||||
|
return str(id).replace('-', '_')
|
||||||
|
|
||||||
|
|
||||||
|
def check_range(column: str, min=1, max=None) -> CheckConstraint:
|
||||||
|
"""Database constraint for ranged values."""
|
||||||
|
constraint = (
|
||||||
|
'>= {}'.format(min) if max is None else 'BETWEEN {} AND {}'.format(min, max)
|
||||||
|
)
|
||||||
|
return CheckConstraint('{} {}'.format(column, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def check_lower(field_name: str):
|
||||||
|
"""Constraint that checks if the string is lower case."""
|
||||||
|
return CheckConstraint(
|
||||||
|
'{0} = lower({0})'.format(field_name),
|
||||||
|
name='{} must be lower'.format(field_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayOfEnum(ARRAY):
|
||||||
|
"""
|
||||||
|
Allows to use Arrays of Enums for psql.
|
||||||
|
|
||||||
|
From `the docs <http://docs.sqlalchemy.org/en/latest/dialects/
|
||||||
|
postgresql.html?highlight=array#postgresql-array-of-enum>`_
|
||||||
|
and `this issue <https://bitbucket.org/zzzeek/sqlalchemy/issues/
|
||||||
|
3467/array-of-enums-does-not-allow-assigning>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def bind_expression(self, bindvalue):
|
||||||
|
return cast(bindvalue, self)
|
||||||
|
|
||||||
|
def result_processor(self, dialect, coltype):
|
||||||
|
super_rp = super(ArrayOfEnum, self).result_processor(dialect, coltype)
|
||||||
|
|
||||||
|
def handle_raw_string(value):
|
||||||
|
inner = re.match(r'^{(.*)}$', value).group(1)
|
||||||
|
return inner.split(',') if inner else []
|
||||||
|
|
||||||
|
def process(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return super_rp(handle_raw_string(value))
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
|
||||||
|
class SQLAlchemy(FlaskSQLAlchemy):
|
||||||
|
"""
|
||||||
|
Enhances :class:`flask_sqlalchemy.SQLAlchemy` by adding our
|
||||||
|
Session and Model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
StrictVersionType = StrictVersionType
|
||||||
|
URL = URL
|
||||||
|
IP = IP
|
||||||
|
IntEnum = IntEnum
|
||||||
|
UUIDLtree = UUIDLtree
|
||||||
|
ArrayOfEnum = ArrayOfEnum
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app=None,
|
||||||
|
use_native_unicode=True,
|
||||||
|
session_options=None,
|
||||||
|
metadata=None,
|
||||||
|
query_class=BaseQuery,
|
||||||
|
model_class=Model,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app, use_native_unicode, session_options, metadata, query_class, model_class
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_session(self, options):
|
||||||
|
"""As parent's create_session but adding our Session."""
|
||||||
|
return sessionmaker(class_=Session, db=self, **options)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaSQLAlchemy(SQLAlchemy):
|
||||||
|
"""
|
||||||
|
Enhances :class:`flask_sqlalchemy.SQLAlchemy` by using PostgreSQL's
|
||||||
|
schemas when creating/dropping tables.
|
||||||
|
|
||||||
|
See :attr:`teal.config.SCHEMA` for more info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app=None,
|
||||||
|
use_native_unicode=True,
|
||||||
|
session_options=None,
|
||||||
|
metadata=None,
|
||||||
|
query_class=Query,
|
||||||
|
model_class=Model,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app, use_native_unicode, session_options, metadata, query_class, model_class
|
||||||
|
)
|
||||||
|
# The following listeners set psql's search_path to the correct
|
||||||
|
# schema and create the schemas accordingly
|
||||||
|
|
||||||
|
# Specifically:
|
||||||
|
# 1. Creates the schemas and set ``search_path`` to app's config SCHEMA
|
||||||
|
event.listen(self.metadata, 'before_create', self.create_schemas)
|
||||||
|
# Set ``search_path`` to default (``public``)
|
||||||
|
event.listen(self.metadata, 'after_create', self.revert_connection)
|
||||||
|
# Set ``search_path`` to app's config SCHEMA
|
||||||
|
event.listen(self.metadata, 'before_drop', self.set_search_path)
|
||||||
|
# Set ``search_path`` to default (``public``)
|
||||||
|
event.listen(self.metadata, 'after_drop', self.revert_connection)
|
||||||
|
|
||||||
|
def create_all(self, bind='__all__', app=None, exclude_schema=None):
|
||||||
|
"""Create all tables.
|
||||||
|
|
||||||
|
:param exclude_schema: Do not create tables in this schema.
|
||||||
|
"""
|
||||||
|
app = self.get_app(app)
|
||||||
|
# todo how to pass exclude_schema without contaminating self?
|
||||||
|
self._exclude_schema = exclude_schema
|
||||||
|
super().create_all(bind, app)
|
||||||
|
|
||||||
|
def _execute_for_all_tables(self, app, bind, operation, skip_tables=False):
|
||||||
|
# todo how to pass app to our event listeners without contaminating self?
|
||||||
|
self._app = self.get_app(app)
|
||||||
|
super()._execute_for_all_tables(app, bind, operation, skip_tables)
|
||||||
|
|
||||||
|
def get_tables_for_bind(self, bind=None):
|
||||||
|
"""As super method, but only getting tales that are not
|
||||||
|
part of exclude_schema, if set.
|
||||||
|
"""
|
||||||
|
tables = super().get_tables_for_bind(bind)
|
||||||
|
if getattr(self, '_exclude_schema', None):
|
||||||
|
tables = [t for t in tables if t.schema != self._exclude_schema]
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def create_schemas(self, target, connection, **kw):
|
||||||
|
"""
|
||||||
|
Create the schemas and set the active schema.
|
||||||
|
|
||||||
|
From `here <https://bitbucket.org/zzzeek/sqlalchemy/issues/3914/
|
||||||
|
extend-create_all-drop_all-to-include#comment-40129850>`_.
|
||||||
|
"""
|
||||||
|
schemas = set(table.schema for table in target.tables.values() if table.schema)
|
||||||
|
if self._app.schema:
|
||||||
|
schemas.add(self._app.schema)
|
||||||
|
for schema in schemas:
|
||||||
|
connection.execute('CREATE SCHEMA IF NOT EXISTS {}'.format(schema))
|
||||||
|
self.set_search_path(target, connection)
|
||||||
|
|
||||||
|
def set_search_path(self, _, connection, **kw):
|
||||||
|
app = self.get_app()
|
||||||
|
if app.schema:
|
||||||
|
connection.execute('SET search_path TO {}, public'.format(app.schema))
|
||||||
|
|
||||||
|
def revert_connection(self, _, connection, **kw):
|
||||||
|
connection.execute('SET search_path TO public')
|
||||||
|
|
||||||
|
def create_session(self, options):
|
||||||
|
"""As parent's create_session but adding our SchemaSession."""
|
||||||
|
return sessionmaker(class_=SchemaSession, db=self, **options)
|
||||||
|
|
||||||
|
def drop_schema(self, app=None, schema=None):
|
||||||
|
"""Nukes a schema and everything that depends on it."""
|
||||||
|
app = self.get_app(app)
|
||||||
|
schema = schema or app.schema
|
||||||
|
with self.engine.begin() as conn:
|
||||||
|
conn.execute('DROP SCHEMA IF EXISTS {} CASCADE'.format(schema))
|
||||||
|
|
||||||
|
def has_schema(self, schema: str) -> bool:
|
||||||
|
"""Does the db have the passed-in schema?"""
|
||||||
|
return self.engine.execute(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname='{}')".format(
|
||||||
|
schema
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
class DBError(BadRequest):
|
||||||
|
"""An Error from the database.
|
||||||
|
|
||||||
|
This helper error is used to map SQLAlchemy's IntegrityError
|
||||||
|
to more precise errors (like UniqueViolation) that are understood
|
||||||
|
as a client-ready HTTP Error.
|
||||||
|
|
||||||
|
When instantiating the class it auto-selects the best error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, origin: IntegrityError):
|
||||||
|
super().__init__(str(origin))
|
||||||
|
self._origin = origin
|
||||||
|
|
||||||
|
def __new__(cls, origin: IntegrityError) -> Any:
|
||||||
|
msg = str(origin)
|
||||||
|
if 'unique constraint' in msg.lower():
|
||||||
|
return super().__new__(UniqueViolation)
|
||||||
|
return super().__new__(cls)
|
||||||
|
|
||||||
|
|
||||||
|
class UniqueViolation(DBError):
|
||||||
|
def __init__(self, origin: IntegrityError):
|
||||||
|
super().__init__(origin)
|
||||||
|
self.constraint = self.description.split('"')[1]
|
||||||
|
self.field_name = None
|
||||||
|
self.field_value = None
|
||||||
|
if isinstance(origin.params, dict):
|
||||||
|
self.field_name, self.field_value = next(
|
||||||
|
(k, v) for k, v in origin.params.items() if k in self.constraint
|
||||||
|
)
|
4421
ereuse_devicehub/teal/enums.py
Normal file
4421
ereuse_devicehub/teal/enums.py
Normal file
File diff suppressed because it is too large
Load diff
11
ereuse_devicehub/teal/json_util.py
Normal file
11
ereuse_devicehub/teal/json_util.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import ereuse_devicehub.ereuse_utils
|
||||||
|
from flask.json import JSONEncoder as FlaskJSONEncoder
|
||||||
|
from sqlalchemy.ext.baked import Result
|
||||||
|
from sqlalchemy.orm import Query
|
||||||
|
|
||||||
|
|
||||||
|
class TealJSONEncoder(ereuse_devicehub.ereuse_utils.JSONEncoder, FlaskJSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, (Result, Query)):
|
||||||
|
return tuple(obj)
|
||||||
|
return super().default(obj)
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue