resolve conflict

This commit is contained in:
Cayo Puigdefabregas 2023-05-23 15:28:13 +02:00
commit 4e610f0903
138 changed files with 17547 additions and 2503 deletions

View file

@ -6,7 +6,34 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
ml).
## testing
## [2.5.2] - 2023-04-20
- [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
- [added] #407 erasure section with tabs in top.

View file

@ -1 +1 @@
__version__ = "2.5.0"
__version__ = "2.5.2"

View file

@ -1,9 +1,9 @@
from sqlalchemy.exc import DataError
from teal.auth import TokenAuth
from teal.db import ResourceNotFound
from werkzeug.exceptions import Unauthorized
from ereuse_devicehub.resources.user.models import User, Session
from ereuse_devicehub.resources.user.models import Session, User
from ereuse_devicehub.teal.auth import TokenAuth
from ereuse_devicehub.teal.db import ResourceNotFound
class Auth(TokenAuth):

View file

@ -2,21 +2,23 @@ import os
import click.testing
import flask.cli
import ereuse_utils
import ereuse_devicehub.ereuse_utils
from ereuse_devicehub.config import DevicehubConfig
from ereuse_devicehub.devicehub import Devicehub
import sys
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
history_file = os.path.join(os.environ['HOME'], '.python_history')
try:
readline.read_history_file(history_file)
readline.read_history_file(history_file)
except IOError:
pass
pass
readline.parse_and_bind("tab: complete")
readline.parse_and_bind('"\e[5~": history-search-backward')
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)
atexit.register(readline.write_history_file, history_file)
class DevicehubGroup(flask.cli.FlaskGroup):
# todo users cannot make cli to use a custom db this way!
CONFIG = DevicehubConfig
@ -49,26 +52,37 @@ class DevicehubGroup(flask.cli.FlaskGroup):
def get_version(ctx, param, value):
if not value or ctx.resilient_parsing:
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)
@click.option('--version',
help='Devicehub version.',
expose_value=False,
callback=get_version,
is_flag=True,
is_eager=True)
@click.group(cls=DevicehubGroup,
context_settings=Devicehub.cli_context_settings,
add_version_option=False,
help="""Manages the Devicehub of the inventory {}.
@click.option(
'--version',
help='Devicehub version.',
expose_value=False,
callback=get_version,
is_flag=True,
is_eager=True,
)
@click.group(
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
manages. For example 'export dhi=db1' and then executing
'dh tag add' adds a tag in the db1 database. Operations
that affect the common database (like creating an user)
are not affected by this.
""".format(os.environ.get('dhi')))
""".format(
os.environ.get('dhi')
),
)
def cli():
pass

View file

@ -1,14 +1,14 @@
from inspect import isclass
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_wtf.csrf import generate_csrf
from teal.client import Client as TealClient
from teal.client import Query, Status
from werkzeug.exceptions import HTTPException
from ereuse_devicehub.resources import models, schemas
from ereuse_devicehub.teal.client import Client as TealClient
from ereuse_devicehub.teal.client import Query, Status
ResourceLike = Union[Type[Union[models.Thing, schemas.Thing]], str]

View file

@ -2,10 +2,6 @@ from distutils.version import StrictVersion
from itertools import chain
from decouple import config
from teal.auth import TokenAuth
from teal.config import Config
from teal.enums import Currency
from teal.utils import import_resource
from ereuse_devicehub.resources import (
action,
@ -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.tradedocument import definitions as tradedocument
from ereuse_devicehub.resources.versions import versions
from ereuse_devicehub.teal.auth import TokenAuth
from ereuse_devicehub.teal.config import Config
from ereuse_devicehub.teal.enums import Currency
from ereuse_devicehub.teal.utils import import_resource
class DevicehubConfig(Config):

View file

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

View file

@ -5,13 +5,11 @@ from typing import Type
import boltons.urlutils
import click
import click_spinner
import ereuse_utils.cli
from ereuse_utils.session import DevicehubClient
import ereuse_devicehub.ereuse_utils.cli
from ereuse_devicehub.ereuse_utils.session import DevicehubClient
from flask import _app_ctx_stack, g
from flask_login import LoginManager, current_user
from flask_sqlalchemy import SQLAlchemy
from teal.db import ResourceNotFound, SchemaSQLAlchemy
from teal.teal import Teal
from ereuse_devicehub.auth import Auth
from ereuse_devicehub.client import Client, UserClient
@ -24,6 +22,8 @@ from ereuse_devicehub.dummy.dummy import Dummy
from ereuse_devicehub.resources.device.search import DeviceSearch
from ereuse_devicehub.resources.inventory import Inventory, InventoryDef
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.db import ResourceNotFound, SchemaSQLAlchemy
from ereuse_devicehub.teal.teal import Teal
from ereuse_devicehub.templating import Environment
@ -122,7 +122,7 @@ class Devicehub(Teal):
@click.option(
'--tag-url',
'-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',
help='The base url (scheme and host) of the tag provider.',
)

View file

@ -5,10 +5,10 @@ from pathlib import Path
import click
import click_spinner
import ereuse_utils.cli
import jwt
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.db import db

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

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

View 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

View 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

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

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

View 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

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

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

View 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

View file

@ -32,6 +32,7 @@ from wtforms.fields import FormField
from ereuse_devicehub.db import db
from ereuse_devicehub.inventory.models import (
DeliveryNote,
DeviceDocument,
ReceiverNote,
Transfer,
TransferCustomerDetails,
@ -69,7 +70,7 @@ from ereuse_devicehub.resources.device.models import (
from ereuse_devicehub.resources.documents.models import DataWipeDocument
from ereuse_devicehub.resources.enums import Severity
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.tradedocument.models import TradeDocument
from ereuse_devicehub.resources.user.models import User
@ -110,6 +111,15 @@ DEVICES = {
"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']
MONITORS = ["ComputerMonitor", "Monitor", "TelevisionSet", "Projector"]
@ -150,11 +160,14 @@ class FilterForm(FlaskForm):
'', 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)
super().__init__(*args, **kwargs)
self.lots = lots
self.lot = lot
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()
def _get_types(self):
@ -165,8 +178,7 @@ class FilterForm(FlaskForm):
self.filter.data = self.device_type
def filter_from_lots(self):
if self.lot_id:
self.lot = self.lots.filter(Lot.id == self.lot_id).one()
if self.lot:
device_ids = (d.id for d in self.lot.devices)
self.devices = Device.query.filter(Device.id.in_(device_ids)).filter(
Device.binding == None # noqa: E711
@ -246,7 +258,8 @@ class LotForm(FlaskForm):
return self.id
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()
db.session.commit()
return self.instance
@ -459,8 +472,6 @@ class NewDeviceForm(FlaskForm):
if self._obj.placeholder.is_abstract:
self.type.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.components.render_kw = disabled
self.serial_number.render_kw = disabled
@ -674,6 +685,14 @@ class NewDeviceForm(FlaskForm):
):
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(
type="Update", source='Web form', placeholder=self._obj.placeholder
)
@ -1275,8 +1294,24 @@ class TradeDocumentForm(FlaskForm):
def __init__(self, *args, **kwargs):
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._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:
self.form_errors = ['Error, this lot is not a transfer lot.']
@ -1292,22 +1327,143 @@ class TradeDocumentForm(FlaskForm):
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)
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._obj.file_name = file_name
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:
db.session.commit()
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):
lot_name = StringField(

View file

@ -1,15 +1,17 @@
from uuid import uuid4
from citext import CIText
from dateutil.tz import tzutc
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.orm import backref, relationship
from teal.db import CASCADE_OWN, URL
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
class Transfer(Thing):
@ -110,3 +112,50 @@ class TransferCustomerDetails(Thing):
),
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()
)

View file

@ -14,6 +14,7 @@ from flask import current_app as app
from flask import g, make_response, request, url_for
from flask.views import View
from flask_login import current_user, login_required
from sqlalchemy import or_
from werkzeug.exceptions import NotFound
from ereuse_devicehub import messages
@ -24,6 +25,7 @@ from ereuse_devicehub.inventory.forms import (
BindingForm,
CustomerDetailsForm,
DataWipeForm,
DeviceDocumentForm,
EditTransferForm,
FilterForm,
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.enums import SnapshotSoftware
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.views import GenericMixin
@ -72,19 +74,25 @@ class DeviceListMixin(GenericMixin):
per_page = int(request.args.get('per_page', PER_PAGE))
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']
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.first = per_page * devices.page - per_page + 1
devices.last = len(devices.items) + devices.first - 1
lot = None
form_transfer = ''
form_delivery = ''
form_receiver = ''
form_customer_details = ''
if lot_id:
if lot_id and not lot:
lot = lots.filter(Lot.id == lot_id).one()
if not lot.is_temporary and lot.transfer:
form_transfer = EditTransferForm(lot_id=lot.id)
@ -110,6 +118,7 @@ class DeviceListMixin(GenericMixin):
'list_devices': self.get_selected_devices(form_new_action),
'all_devices': all_devices,
'filter': filter,
'share_lots': share_lots,
}
)
@ -536,8 +545,9 @@ class LotDeleteView(View):
def dispatch_request(self, id):
form = LotForm(id=id)
if form.instance.trade:
msg = "Sorry, the lot cannot be deleted because have a trade action "
shared = ShareLot.query.filter_by(lot=form.instance).first()
if form.instance.trade or shared:
msg = "Sorry, the lot cannot be deleted because this lot is share"
messages.error(msg)
next_url = url_for('inventory.lotdevicelist', lot_id=id)
return flask.redirect(next_url)
@ -547,6 +557,27 @@ class LotDeleteView(View):
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):
methods = ['GET', 'POST']
decorators = [login_required]
@ -789,6 +820,69 @@ class NewTradeView(DeviceListMixin, NewActionView):
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):
methods = ['POST', 'GET']
decorators = [login_required]
@ -810,6 +904,27 @@ class NewTradeDocumentView(GenericMixin):
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):
methods = ['POST', 'GET']
template_name = 'inventory/new_transfer.html'
@ -899,9 +1014,20 @@ class ExportsView(View):
return export_ids[export_id]()
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')
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))
def response_csv(self, data, name):
@ -1149,7 +1275,7 @@ class ExportsView(View):
n_computers = len({x.parent for x in erasures} - erasures_host)
params = {
'title': 'Erasure Certificate',
'title': 'Device Sanitization',
'erasures': tuple(erasures),
'url_pdf': '',
'date_report': '{:%c}'.format(datetime.datetime.now()),
@ -1196,12 +1322,18 @@ class ExportsView(View):
'Receiver Note Date',
'Receiver Note Units',
'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 ''
receiver_note = lot.transfer and lot.transfer.receiver_note or ''
customer = lot.transfer and lot.transfer.customer_details or ''
wb_devs = 0
placeholders = 0
@ -1214,10 +1346,13 @@ class ExportsView(View):
elif snapshots[-1].software in [SnapshotSoftware.Workbench]:
wb_devs += 1
type_lot = lot.type_transfer()
if lot in share_lots:
type_lot = "Shared"
row = [
lot.id,
lot.name,
lot.type_transfer(),
type_lot,
lot.transfer and (lot.transfer.closed and 'Closed' or 'Open') or '',
lot.transfer and lot.transfer.code 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.units or '',
receiver_note and receiver_note.weight or '',
customer and customer.company_name or '',
customer and customer.location or '',
]
cw.writerow(row)
@ -1264,11 +1401,14 @@ class ExportsView(View):
for dev in self.find_devices():
for lot in dev.lots:
type_lot = lot.type_transfer()
if lot.is_shared:
type_lot = "Shared"
row = [
dev.devicehub_id,
lot.id,
lot.name,
lot.type_transfer(),
type_lot,
lot.transfer and (lot.transfer.closed and 'Closed' or 'Open') or '',
lot.transfer and lot.transfer.code 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')
)
devices.add_url_rule(
'/lot/<string:lot_id>/trade-document/add/',
view_func=NewTradeDocumentView.as_view('trade_document_add'),
'/device/<string:dhid>/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(

View file

@ -8,7 +8,7 @@ from requests.exceptions import ConnectionError
from ereuse_devicehub import __version__, messages
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
labels = Blueprint('labels', __name__, url_prefix='/labels')
@ -23,6 +23,7 @@ class TagListView(View):
def dispatch_request(self):
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(
Tag.created.desc()
)
@ -31,6 +32,7 @@ class TagListView(View):
'tags': tags,
'page_title': 'Unique Identifiers Management',
'version': __version__,
'share_lots': share_lots,
}
return flask.render_template(self.template_name, **context)
@ -42,7 +44,13 @@ class TagAddView(View):
def dispatch_request(self):
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()
if form.validate_on_submit():
form.save()
@ -59,10 +67,12 @@ class TagAddUnnamedView(View):
def dispatch_request(self):
lots = Lot.query.filter(Lot.owner_id == current_user.id)
share_lots = ShareLot.query.filter_by(user_to_id=current_user.id)
context = {
'page_title': 'New Unnamed Tag',
'lots': lots,
'version': __version__,
'share_lots': share_lots,
}
form = TagUnnamedForm()
if form.validate_on_submit():
@ -94,11 +104,13 @@ class PrintLabelsView(View):
def dispatch_request(self):
lots = Lot.query.filter(Lot.owner_id == current_user.id)
share_lots = ShareLot.query.filter_by(user_to_id=current_user.id)
context = {
'lots': lots,
'page_title': self.title,
'version': __version__,
'referrer': request.referrer,
'share_lots': share_lots,
}
form = PrintLabelsForm()
@ -123,6 +135,7 @@ class LabelDetailView(View):
def dispatch_request(self, 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.query.filter(Tag.owner_id == current_user.id).filter(Tag.id == id).one()
)
@ -131,6 +144,7 @@ class LabelDetailView(View):
'page_title': self.title,
'version': __version__,
'referrer': request.referrer,
'share_lots': share_lots,
}
devices = []

View file

@ -1,14 +1,33 @@
from marshmallow.fields import missing_
from teal.db import SQLAlchemy
from teal.marshmallow import NestedOn as TealNestedOn
from ereuse_devicehub.db import db
from ereuse_devicehub.teal.db import SQLAlchemy
from ereuse_devicehub.teal.marshmallow import NestedOn as TealNestedOn
class NestedOn(TealNestedOn):
__doc__ = TealNestedOn.__doc__
def __init__(self, nested, polymorphic_on='type', db: SQLAlchemy = db, collection_class=list,
default=missing_, exclude=tuple(), only_query: str = None, only=None, **kwargs):
super().__init__(nested, polymorphic_on, db, collection_class, default, exclude,
only_query, only, **kwargs)
def __init__(
self,
nested,
polymorphic_on='type',
db: SQLAlchemy = db,
collection_class=list,
default=missing_,
exclude=tuple(),
only_query: str = None,
only=None,
**kwargs,
):
super().__init__(
nested,
polymorphic_on,
db,
collection_class,
default,
exclude,
only_query,
only,
**kwargs,
)

View file

@ -9,7 +9,7 @@ from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
import citext
import teal
from ereuse_devicehub import teal
${imports if imports else ""}
# revision identifiers, used by Alembic.

View file

@ -10,7 +10,7 @@ from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
import citext
import teal
from ereuse_devicehub import teal
# revision identifiers, used by Alembic.
@ -26,11 +26,32 @@ def get_inv():
raise ValueError("Inventory value is not specified")
return INV
def upgrade():
op.alter_column('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()}')
op.alter_column(
'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():
op.alter_column('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()}')
op.alter_column(
'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()}',
)

View file

@ -11,7 +11,7 @@ from sqlalchemy.dialects import postgresql
import sqlalchemy as sa
import sqlalchemy_utils
import citext
import teal
from ereuse_devicehub import teal
from ereuse_devicehub.resources.enums import SessionType

View file

@ -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()}')

View file

@ -5,12 +5,12 @@ Revises: bf600ca861a4
Create Date: 2020-12-16 11:45:13.339624
"""
from alembic import context
from alembic import op
import citext
import sqlalchemy as sa
import sqlalchemy_utils
import citext
import teal
from alembic import context
from alembic import op
from ereuse_devicehub import teal
# revision identifiers, used by Alembic.

View file

@ -5,15 +5,14 @@ Revises: 51439cf24be8
Create Date: 2021-06-15 14:38:59.931818
"""
import teal
import citext
import sqlalchemy as sa
from ereuse_devicehub import teal
from alembic import op
from alembic import context
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '3a3601ac8224'
down_revision = '51439cf24be8'
@ -27,108 +26,143 @@ def get_inv():
raise ValueError("Inventory value is not specified")
return INV
def upgrade():
op.create_table('trade_document',
sa.Column(
'updated',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='The last time Devicehub recorded a change for \n this thing.\n '
),
sa.Column(
'created',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='When Devicehub created this.'
),
sa.Column(
'id',
sa.BigInteger(),
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.DateTime(),
nullable=True,
comment='The date of document, some documents need to have one date\n '
),
sa.Column(
'id_document',
citext.CIText(),
nullable=True,
comment='The id of one document like invoice so they can be linked.'
),
sa.Column(
'description',
citext.CIText(),
nullable=True,
comment='A description of document.'
),
sa.Column(
'owner_id',
postgresql.UUID(as_uuid=True),
nullable=False
),
sa.Column(
'lot_id',
postgresql.UUID(as_uuid=True),
nullable=False
),
sa.Column(
'file_name',
citext.CIText(),
nullable=True,
comment='This is the name of the file when user up the document.'
),
sa.Column(
'file_hash',
citext.CIText(),
nullable=True,
comment='This is the hash of the file produced from frontend.'
),
sa.Column(
'url',
citext.CIText(),
teal.db.URL(),
nullable=True,
comment='This is the url where resides the document.'
),
sa.ForeignKeyConstraint(['lot_id'], [f'{get_inv()}.lot.id'],),
sa.ForeignKeyConstraint(['owner_id'], ['common.user.id'],),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}'
op.create_table(
'trade_document',
sa.Column(
'updated',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='The last time Devicehub recorded a change for \n this thing.\n ',
),
sa.Column(
'created',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='When Devicehub created this.',
),
sa.Column(
'id',
sa.BigInteger(),
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.DateTime(),
nullable=True,
comment='The date of document, some documents need to have one date\n ',
),
sa.Column(
'id_document',
citext.CIText(),
nullable=True,
comment='The id of one document like invoice so they can be linked.',
),
sa.Column(
'description',
citext.CIText(),
nullable=True,
comment='A description of document.',
),
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column(
'file_name',
citext.CIText(),
nullable=True,
comment='This is the name of the file when user up the document.',
),
sa.Column(
'file_hash',
citext.CIText(),
nullable=True,
comment='This is the hash of the file produced from frontend.',
),
sa.Column(
'url',
citext.CIText(),
teal.db.URL(),
nullable=True,
comment='This is the url where resides the document.',
),
sa.ForeignKeyConstraint(
['lot_id'],
[f'{get_inv()}.lot.id'],
),
sa.ForeignKeyConstraint(
['owner_id'],
['common.user.id'],
),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}',
)
# Action document table
op.create_table('action_trade_document',
sa.Column('document_id', sa.BigInteger(), nullable=False),
sa.Column('action_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['action_id'], [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_table(
'action_trade_document',
sa.Column('document_id', sa.BigInteger(), nullable=False),
sa.Column('action_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
['action_id'],
[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.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_index(
'document_id',
'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',
sa.Column('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),
op.create_table(
'confirm_document',
sa.Column('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():
op.drop_table('action_trade_document', schema=f'{get_inv()}')
op.drop_table('confirm_document', schema=f'{get_inv()}')
op.drop_table('trade_document', schema=f'{get_inv()}')

View file

@ -7,10 +7,11 @@ Create Date: 2023-02-13 18:01:00.092527
"""
import citext
import sqlalchemy as sa
import teal
from alembic import context, op
from sqlalchemy.dialects import postgresql
from ereuse_devicehub import teal
# revision identifiers, used by Alembic.
revision = '4f33137586dd'
down_revision = '8334535d56fa'

View file

@ -9,7 +9,7 @@ from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
import citext
import teal
from ereuse_devicehub import teal
from alembic import op
from alembic import context
@ -32,51 +32,98 @@ def get_inv():
def upgrade():
# Document table
op.create_table('document',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='The last time Document recorded a change for \n this thing.\n '),
sa.Column('created', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False, comment='When Document created this.'),
sa.Column('document_type', sa.Unicode(), nullable=False),
sa.Column('date', sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column('id_document', sa.Unicode(), nullable=True),
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('file_name', sa.Unicode(), nullable=False),
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()}')
op.create_table(
'document',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column(
'updated',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='The last time Document recorded a change for \n this thing.\n ',
),
sa.Column(
'created',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='When Document created this.',
),
sa.Column('document_type', sa.Unicode(), nullable=False),
sa.Column('date', sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column('id_document', sa.Unicode(), nullable=True),
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('file_name', sa.Unicode(), nullable=False),
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
op.create_table('data_wipe_document',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('software', sa.Unicode(), nullable=True),
sa.Column('success', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.document.id'], ),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}'
)
op.create_table(
'data_wipe_document',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('software', sa.Unicode(), nullable=True),
sa.Column('success', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
['id'],
[f'{get_inv()}.document.id'],
),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}',
)
# DataWipe table
op.create_table('data_wipe',
sa.Column('document_id', sa.BigInteger(), nullable=False),
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['document_id'], [f'{get_inv()}.document.id'], ),
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}'
)
op.create_table(
'data_wipe',
sa.Column('document_id', sa.BigInteger(), nullable=False),
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
['document_id'],
[f'{get_inv()}.document.id'],
),
sa.ForeignKeyConstraint(
['id'],
[f'{get_inv()}.action.id'],
),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}',
)
def downgrade():

View file

@ -10,7 +10,7 @@ from alembic import context
import sqlalchemy as sa
import sqlalchemy_utils
import citext
import teal
from ereuse_devicehub import teal
# revision identifiers, used by Alembic.
@ -26,10 +26,10 @@ def get_inv():
raise ValueError("Inventory value is not specified")
return INV
def upgrade():
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'"
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)]
@ -40,12 +40,12 @@ def upgrade():
revoke_id = ac.action_id
trade_id = revokes[revoke_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_confirm)
def downgrade():
pass

View file

@ -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()}')

View file

@ -6,7 +6,7 @@ Create Date: 2020-12-29 20:19:46.981207
"""
import sqlalchemy as sa
import teal
from ereuse_devicehub import teal
from alembic import context, op
from sqlalchemy.dialects import postgresql

View file

@ -10,7 +10,7 @@ from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
import citext
import teal
from ereuse_devicehub import teal
# revision identifiers, used by Alembic.
@ -26,6 +26,7 @@ def get_inv():
raise ValueError("Inventory value is not specified")
return INV
def upgrade():
con = op.get_bind()
sql = f"""
@ -60,6 +61,5 @@ def upgrade():
con.execute(sql)
def downgrade():
pass

View file

@ -10,7 +10,7 @@ import sqlalchemy as sa
from alembic import context
import sqlalchemy_utils
import citext
import teal
from ereuse_devicehub import teal
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
@ -26,48 +26,85 @@ def get_inv():
raise ValueError("Inventory value is not specified")
return INV
def upgrade():
# Allocate action
op.drop_table('allocate', schema=f'{get_inv()}')
op.create_table('allocate',
sa.Column('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.'),
op.create_table(
'allocate',
sa.Column(
'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('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'),
schema=f'{get_inv()}'
schema=f'{get_inv()}',
)
# Deallocate action
op.drop_table('deallocate', schema=f'{get_inv()}')
op.create_table('deallocate',
sa.Column('transaction', citext.CIText(), nullable=True, comment='The code used from the owner for relation with external tool.'),
op.create_table(
'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.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ),
sa.ForeignKeyConstraint(
['id'],
[f'{get_inv()}.action.id'],
),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}'
schema=f'{get_inv()}',
)
# 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
op.drop_table('receive', schema=f'{get_inv()}')
# Live action
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('serial_number', sa.Unicode(), nullable=True,
comment='The serial number of the Hard Disk in lower case.'),
sa.Column(
'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('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'),
schema=f'{get_inv()}'
schema=f'{get_inv()}',
)
def downgrade():
op.drop_table('allocate', schema=f'{get_inv()}')

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@ from math import hypot
from typing import Iterator, List, Optional, TypeVar
import dateutil.parser
from ereuse_utils import getter, text
from ereuse_utils.nested_lookup import (
from ereuse_devicehub.ereuse_utils import getter, text
from ereuse_devicehub.ereuse_utils.nested_lookup import (
get_nested_dicts_with_key_containing_value,
get_nested_dicts_with_key_value,
)

View file

@ -5,7 +5,7 @@ import struct
from contextlib import contextmanager
from enum import Enum
from ereuse_utils import Dumpeable
from ereuse_devicehub.ereuse_utils import Dumpeable
class Severity(Enum):

View file

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

View file

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

View file

@ -23,7 +23,6 @@ from typing import Optional, Set, Union
from uuid import uuid4
import inflection
import teal.db
from boltons import urlutils
from citext import CIText
from dateutil.tz import tzutc
@ -50,19 +49,8 @@ from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import backref, relationship, validates
from sqlalchemy.orm.events import AttributeEvents as Events
from sqlalchemy.util import OrderedSet
from teal.db import (
CASCADE_OWN,
INHERIT_COND,
POLYMORPHIC_ID,
POLYMORPHIC_ON,
URL,
StrictVersionType,
check_lower,
check_range,
)
from teal.enums import Currency
from teal.resource import url_for_resource
import ereuse_devicehub.teal.db
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.agent.models import Agent
from ereuse_devicehub.resources.device.metrics import TradeMetrics
@ -94,6 +82,18 @@ from ereuse_devicehub.resources.enums import (
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.db import (
CASCADE_OWN,
INHERIT_COND,
POLYMORPHIC_ID,
POLYMORPHIC_ON,
URL,
StrictVersionType,
check_lower,
check_range,
)
from ereuse_devicehub.teal.enums import Currency
from ereuse_devicehub.teal.resource import url_for_resource
class JoinedTableMixin:
@ -125,7 +125,11 @@ class Action(Thing):
name.comment = """A name or title for the action. Used when searching
for actions.
"""
severity = Column(teal.db.IntEnum(Severity), default=Severity.Info, nullable=False)
severity = Column(
ereuse_devicehub.teal.db.IntEnum(Severity),
default=Severity.Info,
nullable=False,
)
severity.comment = Severity.__doc__
closed = Column(Boolean, default=True, nullable=False)
closed.comment = """Whether the author has finished the action.
@ -594,7 +598,11 @@ class Step(db.Model):
)
type = Column(Unicode(STR_SM_SIZE), nullable=False)
num = Column(SmallInteger, primary_key=True)
severity = Column(teal.db.IntEnum(Severity), default=Severity.Info, nullable=False)
severity = Column(
ereuse_devicehub.teal.db.IntEnum(Severity),
default=Severity.Info,
nullable=False,
)
start_time = Column(db.TIMESTAMP(timezone=True), nullable=False)
start_time.comment = Action.start_time.comment
end_time = Column(

View file

@ -21,9 +21,6 @@ from marshmallow.fields import (
)
from marshmallow.validate import Length, OneOf, Range
from sqlalchemy.util import OrderedSet
from teal.enums import Country, Currency, Subdivision
from teal.marshmallow import IP, URL, EnumField, SanitizedStr, Version
from teal.resource import Schema
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources import enums
@ -48,6 +45,9 @@ from ereuse_devicehub.resources.tradedocument import schemas as s_document
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
from ereuse_devicehub.resources.user import schemas as s_user
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.enums import Country, Currency, Subdivision
from ereuse_devicehub.teal.marshmallow import IP, URL, EnumField, SanitizedStr, Version
from ereuse_devicehub.teal.resource import Schema
class Action(Thing):

View file

@ -1,5 +1,4 @@
from flask import g
from teal.marshmallow import ValidationError
from ereuse_devicehub.db import db
from ereuse_devicehub.inventory.models import Transfer
@ -13,6 +12,7 @@ from ereuse_devicehub.resources.action.models import (
)
from ereuse_devicehub.resources.lot.views import delete_from_trade
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.marshmallow import ValidationError
class TradeView:

View file

@ -4,13 +4,10 @@ from datetime import timedelta
from distutils.version import StrictVersion
from uuid import UUID
import ereuse_utils
import ereuse_devicehub.ereuse_utils
import jwt
from flask import current_app as app
from flask import g, request
from teal.db import ResourceNotFound
from teal.marshmallow import ValidationError
from teal.resource import View
from ereuse_devicehub.db import db
from ereuse_devicehub.query import things_response
@ -35,6 +32,9 @@ from ereuse_devicehub.resources.action.views.snapshot import (
)
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
from ereuse_devicehub.resources.enums import Severity
from ereuse_devicehub.teal.db import ResourceNotFound
from ereuse_devicehub.teal.marshmallow import ValidationError
from ereuse_devicehub.teal.resource import View
SUPPORTED_WORKBENCH = StrictVersion('11.0')
@ -203,7 +203,7 @@ def decode_snapshot(data):
data['data'],
app.config['JWT_PASS'],
algorithms="HS256",
json_encoder=ereuse_utils.JSONEncoder,
json_encoder=ereuse_devicehub.ereuse_utils.JSONEncoder,
)
except jwt.exceptions.InvalidSignatureError as err:
txt = 'Invalid snapshot'

View file

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

View file

@ -10,14 +10,19 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import backref, relationship, validates
from sqlalchemy_utils import EmailType, PhoneNumberType
from teal import enums
from teal.db import INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON, check_lower
from teal.marshmallow import ValidationError
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.inventory import Inventory
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal import enums
from ereuse_devicehub.teal.db import (
INHERIT_COND,
POLYMORPHIC_ID,
POLYMORPHIC_ON,
check_lower,
)
from ereuse_devicehub.teal.marshmallow import ValidationError
class JoinedTableMixin:

View file

@ -1,19 +1,20 @@
from marshmallow import fields as ma_fields, validate as ma_validate
from marshmallow import fields as ma_fields
from marshmallow import validate as ma_validate
from marshmallow.fields import Email
from teal import enums
from teal.marshmallow import EnumField, Phone, SanitizedStr
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.models import STR_SIZE, STR_SM_SIZE
from ereuse_devicehub.resources.schemas import Thing
from ereuse_devicehub.teal import enums
from ereuse_devicehub.teal.marshmallow import EnumField, Phone, SanitizedStr
class Agent(Thing):
id = ma_fields.UUID(dump_only=True)
name = SanitizedStr(validate=ma_validate.Length(max=STR_SM_SIZE))
tax_id = SanitizedStr(lower=True,
validate=ma_validate.Length(max=STR_SM_SIZE),
data_key='taxId')
tax_id = SanitizedStr(
lower=True, validate=ma_validate.Length(max=STR_SM_SIZE), data_key='taxId'
)
country = EnumField(enums.Country)
telephone = Phone()
email = Email()

View file

@ -1,9 +1,8 @@
from typing import Callable, Iterable, Tuple
from teal.resource import Converters, Resource
from ereuse_devicehub.resources.deliverynote import schemas
from ereuse_devicehub.resources.deliverynote.views import DeliverynoteView
from ereuse_devicehub.teal.resource import Converters, Resource
class DeliverynoteDef(Resource):
@ -12,15 +11,28 @@ class DeliverynoteDef(Resource):
AUTH = True
ID_CONVERTER = Converters.uuid
def __init__(self, app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
def __init__(
self,
app,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
):
super().__init__(
app,
import_name,
static_folder,
static_url_path,
template_folder,
url_prefix,
subdomain,
url_defaults,
root_path,
cli_commands,
)

View file

@ -5,35 +5,47 @@ from typing import Iterable
from boltons import urlutils
from citext import CIText
from flask import g
from sqlalchemy.dialects.postgresql import UUID, JSONB
from teal.db import check_range, IntEnum
from teal.resource import url_for_resource
from sqlalchemy.dialects.postgresql import JSONB, UUID
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.enums import TransferState
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.db import IntEnum, check_range
from ereuse_devicehub.teal.resource import url_for_resource
class Deliverynote(Thing):
id = db.Column(UUID(as_uuid=True), primary_key=True) # uuid is generated on init by default
id = db.Column(
UUID(as_uuid=True), primary_key=True
) # uuid is generated on init by default
document_id = db.Column(CIText(), nullable=False)
creator_id = db.Column(UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id)
creator_id = db.Column(
UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id,
)
creator = db.relationship(User, primaryjoin=creator_id == User.id)
supplier_email = db.Column(CIText(),
db.ForeignKey(User.email),
nullable=False,
default=lambda: g.user.email)
supplier = db.relationship(User, primaryjoin=lambda: Deliverynote.supplier_email == User.email)
receiver_address = db.Column(CIText(),
db.ForeignKey(User.email),
nullable=False,
default=lambda: g.user.email)
receiver = db.relationship(User, primaryjoin=lambda: Deliverynote.receiver_address == User.email)
supplier_email = db.Column(
CIText(),
db.ForeignKey(User.email),
nullable=False,
default=lambda: g.user.email,
)
supplier = db.relationship(
User, primaryjoin=lambda: Deliverynote.supplier_email == User.email
)
receiver_address = db.Column(
CIText(),
db.ForeignKey(User.email),
nullable=False,
default=lambda: g.user.email,
)
receiver = db.relationship(
User, primaryjoin=lambda: Deliverynote.receiver_address == User.email
)
date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
date.comment = 'The date the DeliveryNote initiated'
amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0)
@ -44,27 +56,37 @@ class Deliverynote(Thing):
expected_devices = db.Column(JSONB, nullable=False)
# expected_devices = db.Column(db.ARRAY(JSONB, dimensions=1), nullable=False)
transferred_devices = db.Column(db.ARRAY(db.Integer, dimensions=1), nullable=True)
transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False)
transfer_state = db.Column(
IntEnum(TransferState), default=TransferState.Initial, nullable=False
)
transfer_state.comment = TransferState.__doc__
lot_id = db.Column(UUID(as_uuid=True),
db.ForeignKey(Lot.id),
nullable=False)
lot = db.relationship(Lot,
backref=db.backref('deliverynote', uselist=False, lazy=True),
lazy=True,
primaryjoin=Lot.id == lot_id)
lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
lot = db.relationship(
Lot,
backref=db.backref('deliverynote', uselist=False, lazy=True),
lazy=True,
primaryjoin=Lot.id == lot_id,
)
def __init__(self, document_id: str, amount: str, date,
supplier_email: str,
expected_devices: Iterable,
transfer_state: TransferState) -> None:
"""Initializes a delivery note
"""
super().__init__(id=uuid.uuid4(),
document_id=document_id, amount=amount, date=date,
supplier_email=supplier_email,
expected_devices=expected_devices,
transfer_state=transfer_state)
def __init__(
self,
document_id: str,
amount: str,
date,
supplier_email: str,
expected_devices: Iterable,
transfer_state: TransferState,
) -> None:
"""Initializes a delivery note"""
super().__init__(
id=uuid.uuid4(),
document_id=document_id,
amount=amount,
date=date,
supplier_email=supplier_email,
expected_devices=expected_devices,
transfer_state=transfer_state,
)
@property
def type(self) -> str:

View file

@ -1,5 +1,4 @@
from marshmallow import fields as f
from teal.marshmallow import SanitizedStr, EnumField
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.deliverynote import models as m
@ -7,20 +6,30 @@ from ereuse_devicehub.resources.enums import TransferState
from ereuse_devicehub.resources.models import STR_SIZE
from ereuse_devicehub.resources.schemas import Thing
from ereuse_devicehub.resources.user import schemas as s_user
from ereuse_devicehub.teal.marshmallow import EnumField, SanitizedStr
class Deliverynote(Thing):
id = f.UUID(dump_only=True)
document_id = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
required=True, data_key='documentID')
document_id = SanitizedStr(
validate=f.validate.Length(max=STR_SIZE), required=True, data_key='documentID'
)
creator = NestedOn(s_user.User, dump_only=True)
supplier_email = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
load_only=True, required=True, data_key='supplierEmail')
supplier_email = SanitizedStr(
validate=f.validate.Length(max=STR_SIZE),
load_only=True,
required=True,
data_key='supplierEmail',
)
supplier = NestedOn(s_user.User, dump_only=True)
receiver = NestedOn(s_user.User, dump_only=True)
date = f.DateTime('iso', required=True)
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
description=m.Deliverynote.amount.__doc__)
amount = f.Integer(
validate=f.validate.Range(min=0, max=100),
description=m.Deliverynote.amount.__doc__,
)
expected_devices = f.List(f.Dict, required=True, data_key='expectedDevices')
transferred_devices = f.List(f.Integer(), required=False, data_key='transferredDevices')
transferred_devices = f.List(
f.Integer(), required=False, data_key='transferredDevices'
)
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)

View file

@ -2,21 +2,22 @@ import datetime
import uuid
from flask import Response, request
from teal.resource import View
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.deliverynote.models import Deliverynote
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.teal.resource import View
class DeliverynoteView(View):
def post(self):
# Create delivery note
dn = request.get_json()
dlvnote = Deliverynote(**dn)
# Create a lot
lot_name = dlvnote.document_id + "_" + datetime.datetime.utcnow().strftime("%Y-%m-%d")
lot_name = (
dlvnote.document_id + "_" + datetime.datetime.utcnow().strftime("%Y-%m-%d")
)
new_lot = Lot(name=lot_name)
dlvnote.lot_id = new_lot.id
db.session.add(new_lot)

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import copy
import hashlib
import json
import logging
import os
import pathlib
import time
@ -13,7 +14,6 @@ from typing import Dict, List, Set
from boltons import urlutils
from citext import CIText
from ereuse_utils.naming import HID_CONVERSION_DOC
from ereuseapi.methods import API
from flask import current_app as app
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_utils import ColorType
from stdnum import imei, meid
from teal.db import (
CASCADE_DEL,
POLYMORPHIC_ID,
POLYMORPHIC_ON,
URL,
IntEnum,
ResourceNotFound,
check_lower,
check_range,
)
from teal.enums import Layouts
from teal.marshmallow import ValidationError
from teal.resource import url_for_resource
from ereuse_devicehub.db import db
from ereuse_devicehub.ereuse_utils.naming import HID_CONVERSION_DOC
from ereuse_devicehub.resources.device.metrics import Metrics
from ereuse_devicehub.resources.enums import (
BatteryTechnology,
@ -72,6 +60,21 @@ from ereuse_devicehub.resources.models import (
)
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.resources.utils import hashcode
from ereuse_devicehub.teal.db import (
CASCADE_DEL,
POLYMORPHIC_ID,
POLYMORPHIC_ON,
URL,
IntEnum,
ResourceNotFound,
check_lower,
check_range,
)
from ereuse_devicehub.teal.enums import Layouts
from ereuse_devicehub.teal.marshmallow import ValidationError
from ereuse_devicehub.teal.resource import url_for_resource
logger = logging.getLogger(__name__)
def create_code(context):
@ -750,6 +753,28 @@ class Device(Thing):
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):
from ereuse_devicehub.resources.device import states
@ -785,7 +810,7 @@ class Device(Thing):
def get_from_db(self):
if 'property_hid' in app.blueprints.keys():
try:
from modules.device.utils import get_from_db
from ereuse_devicehub.modules.device.utils import get_from_db
return get_from_db(self)
except Exception:
@ -804,13 +829,13 @@ class Device(Thing):
def set_hid(self):
if 'property_hid' in app.blueprints.keys():
try:
from modules.device.utils import set_hid
from ereuse_devicehub.modules.device.utils import set_hid
self.hid = set_hid(self)
self.set_chid()
return
except Exception:
pass
except Exception as err:
logger.error(err)
self.hid = "{}-{}-{}-{}".format(
self._clean_string(self.type),
@ -1251,6 +1276,13 @@ class Placeholder(Thing):
return 'Twin'
return 'Placeholder'
@property
def documents(self):
docs = self.device.documents
if self.binding:
return docs.union(self.binding.documents)
return docs
class Computer(Device):
"""A chassis with components inside that can be processed

View file

@ -17,9 +17,6 @@ from marshmallow.fields import (
from marshmallow.validate import Length, OneOf, Range
from sqlalchemy.util import OrderedSet
from stdnum import imei, meid
from teal.enums import Layouts
from teal.marshmallow import URL, EnumField, SanitizedStr, ValidationError
from teal.resource import Schema
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources import enums
@ -27,6 +24,14 @@ from ereuse_devicehub.resources.device import models as m
from ereuse_devicehub.resources.device import states
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
from ereuse_devicehub.resources.schemas import Thing, UnitCodes
from ereuse_devicehub.teal.enums import Layouts
from ereuse_devicehub.teal.marshmallow import (
URL,
EnumField,
SanitizedStr,
ValidationError,
)
from ereuse_devicehub.teal.resource import Schema
class Device(Thing):

View file

@ -8,8 +8,6 @@ from flask import g
from sqlalchemy import inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.util import OrderedSet
from teal.db import ResourceNotFound
from teal.marshmallow import ValidationError
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.action.models import Remove
@ -21,6 +19,8 @@ from ereuse_devicehub.resources.device.models import (
Placeholder,
)
from ereuse_devicehub.resources.tag.model import Tag
from ereuse_devicehub.teal.db import ResourceNotFound
from ereuse_devicehub.teal.marshmallow import ValidationError
# DEVICES_ALLOW_DUPLICITY = [
# 'RamModule',

View file

@ -12,10 +12,6 @@ from marshmallow import fields
from marshmallow import fields as f
from marshmallow import validate as v
from sqlalchemy.util import OrderedSet
from teal import query
from teal.cache import cache
from teal.marshmallow import ValidationError
from teal.resource import View
from ereuse_devicehub import auth
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.lot.models import LotDeviceDescendants
from ereuse_devicehub.resources.tag.model import Tag
from ereuse_devicehub.teal import query
from ereuse_devicehub.teal.cache import cache
from ereuse_devicehub.teal.db import ResourceNotFound
from ereuse_devicehub.teal.marshmallow import ValidationError
from ereuse_devicehub.teal.resource import View
class OfType(f.Str):

View file

@ -37,8 +37,12 @@ class BaseDeviceRow(OrderedDict):
self['PHID'] = ''
self['DHID'] = ''
self['Type'] = ''
self['Placeholder Palet'] = ''
self['Temporary Lots'] = ''
self['Incoming Lots'] = ''
self['Outgoing Lots'] = ''
self['Placeholder Pallet'] = ''
self['Placeholder Id Supplier'] = ''
self['Placeholder Id Internal'] = ''
self['Placeholder Info'] = ''
self['Placeholder Components'] = ''
self['Placeholder Type'] = ''
@ -263,7 +267,7 @@ class BaseDeviceRow(OrderedDict):
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__()
self.placeholder = device.binding or device.placeholder
self.device = self.placeholder.binding or self.placeholder.device
@ -504,8 +508,12 @@ class DeviceRow(BaseDeviceRow):
# Placeholder
self['PHID'] = none2str(self.placeholder.phid)
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 Internal'] = none2str(self.placeholder.id_device_internal)
self['Placeholder Info'] = none2str(self.placeholder.info)
self['Placeholder Components'] = none2str(self.placeholder.components)
self['Placeholder Type'] = none2str(self.placeholder.device.type)

View file

@ -11,14 +11,12 @@ from typing import Callable, Iterable, Tuple
import boltons
import flask
import flask_weasyprint
import teal.marshmallow
from boltons import urlutils
from flask import current_app as app
from flask import g, make_response, request
from flask.json import jsonify
from teal.cache import cache
from teal.resource import Resource, View
import ereuse_devicehub.teal.marshmallow
from ereuse_devicehub import auth
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.action import models as evs
@ -37,6 +35,8 @@ from ereuse_devicehub.resources.hash_reports import ReportHash, insert_hash, ver
from ereuse_devicehub.resources.lot import LotView
from ereuse_devicehub.resources.lot.models import Lot
from ereuse_devicehub.resources.user.models import Session
from ereuse_devicehub.teal.cache import cache
from ereuse_devicehub.teal.resource import Resource, View
class Format(enum.Enum):
@ -46,7 +46,7 @@ class Format(enum.Enum):
class DocumentView(DeviceView):
class FindArgs(DeviceView.FindArgs):
format = teal.marshmallow.EnumField(Format, missing=None)
format = ereuse_devicehub.teal.marshmallow.EnumField(Format, missing=None)
def get(self, id):
"""Get a collection of resources or a specific one.
@ -71,7 +71,7 @@ class DocumentView(DeviceView):
if not ids and not id:
msg = 'Document must be an ID or UUID.'
raise teal.marshmallow.ValidationError(msg)
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
if id:
try:
@ -81,7 +81,7 @@ class DocumentView(DeviceView):
ids.append(int(id))
except ValueError:
msg = 'Document must be an ID or UUID.'
raise teal.marshmallow.ValidationError(msg)
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
else:
query = devs.Device.query.filter(Device.id.in_(ids))
else:
@ -98,7 +98,7 @@ class DocumentView(DeviceView):
# try:
# id = int(id)
# except ValueError:
# raise teal.marshmallow.ValidationError('Document must be an ID or UUID.')
# raise ereuse_devicehub.teal.marshmallow.ValidationError('Document must be an ID or UUID.')
# else:
# query = devs.Device.query.filter_by(id=id)
# else:
@ -138,7 +138,7 @@ class DocumentView(DeviceView):
url_pdf = boltons.urlutils.URL(flask.request.url)
url_pdf.query_params['format'] = 'PDF'
params = {
'title': 'Erasure Certificate',
'title': 'Device Sanitization',
'erasures': tuple(erasures()),
'url_pdf': url_pdf.to_text(),
}
@ -280,7 +280,7 @@ class LotRow(OrderedDict):
self['Registered in'] = format(lot.created, '%c')
try:
self['Description'] = lot.description
except:
except Exception:
self['Description'] = ''

View file

@ -1,20 +1,19 @@
from flask import g
from citext import CIText
from flask import g
from sortedcontainers import SortedSet
from sqlalchemy import BigInteger, Column, Sequence, Unicode, Boolean, ForeignKey
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Sequence, Unicode
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import backref
from teal.db import CASCADE_OWN, URL
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.resources.models import Thing, STR_SM_SIZE
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
_sorted_documents = {
'order_by': lambda: Document.created,
'collection_class': SortedSet
'collection_class': SortedSet,
}
@ -30,11 +29,15 @@ class Document(Thing):
date.comment = """The date of document, some documents need to have one date
"""
id_document = Column(CIText(), nullable=True)
id_document.comment = """The id of one document like invoice so they can be linked."""
owner_id = db.Column(UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id)
id_document.comment = (
"""The id of one document like invoice so they can be linked."""
)
owner_id = db.Column(
UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id,
)
owner = db.relationship(User, primaryjoin=owner_id == User.id)
file_name = Column(db.CIText(), nullable=False)
file_name.comment = """This is the name of the file when user up the document."""

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import teal.marshmallow
import ereuse_devicehub.teal.marshmallow
from marshmallow import fields as mf
from ereuse_devicehub.resources.schemas import Thing
@ -7,4 +7,6 @@ from ereuse_devicehub.resources.schemas import Thing
class Inventory(Thing):
id = mf.String(dump_only=True)
name = mf.String(dump_only=True)
tag_provider = teal.marshmallow.URL(dump_only=True, data_key='tagProvider')
tag_provider = ereuse_devicehub.teal.marshmallow.URL(
dump_only=True, data_key='tagProvider'
)

View file

@ -1,6 +1,8 @@
from typing import Callable, Iterable, Tuple
from flask.json import jsonify
from teal.resource import Resource, View
from ereuse_devicehub.teal.resource import Resource, View
class LicenceView(View):
@ -23,18 +25,31 @@ class LicencesDef(Resource):
VIEW = None # We do not want to create default / documents endpoint
AUTH = False
def __init__(self, app,
import_name=__name__,
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
def __init__(
self,
app,
import_name=__name__,
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
):
super().__init__(
app,
import_name,
static_folder,
static_url_path,
template_folder,
url_prefix,
subdomain,
url_defaults,
root_path,
cli_commands,
)
get = {'GET'}
d = {}

View file

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

View file

@ -10,14 +10,14 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy_utils import LtreeType
from sqlalchemy_utils.types.ltree import LQUERY
from teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
from teal.resource import url_for_resource
from ereuse_devicehub.db import create_view, db, exp, f
from ereuse_devicehub.resources.device.models import Component, Device
from ereuse_devicehub.resources.enums import TransferState
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
from ereuse_devicehub.teal.resource import url_for_resource
class Lot(Thing):
@ -125,7 +125,10 @@ class Lot(Thing):
@property
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
def is_incoming(self):
@ -145,6 +148,19 @@ class Lot(Thing):
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
def descendantsq(cls, id):
_id = UUIDLtree.convert(id)
@ -397,3 +413,15 @@ class LotParent(db.Model):
.select_from(Path)
.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)

View file

@ -1,15 +1,14 @@
from marshmallow import fields as f
from teal.marshmallow import SanitizedStr, URL, EnumField
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.action import schemas as s_action
from ereuse_devicehub.resources.deliverynote import schemas as s_deliverynote
from ereuse_devicehub.resources.device import schemas as s_device
from ereuse_devicehub.resources.action import schemas as s_action
from ereuse_devicehub.resources.enums import TransferState
from ereuse_devicehub.resources.lot import models as m
from ereuse_devicehub.resources.models import STR_SIZE
from ereuse_devicehub.resources.schemas import Thing
from ereuse_devicehub.teal.marshmallow import URL, EnumField, SanitizedStr
TRADE_VALUES = (
'id',
@ -18,16 +17,11 @@ TRADE_VALUES = (
'user_from.id',
'user_to.id',
'user_to.code',
'user_from.code'
'user_from.code',
)
DOCUMENTS_VALUES = (
'id',
'file_name',
'total_weight',
'trading'
)
DOCUMENTS_VALUES = ('id', 'file_name', 'total_weight', 'trading')
class Old_Lot(Thing):
@ -39,8 +33,9 @@ class Old_Lot(Thing):
children = NestedOn('Lot', many=True, dump_only=True)
parents = NestedOn('Lot', many=True, dump_only=True)
url = URL(dump_only=True, description=m.Lot.url.__doc__)
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
description=m.Lot.amount.__doc__)
amount = f.Integer(
validate=f.validate.Range(min=0, max=100), description=m.Lot.amount.__doc__
)
# author_id = NestedOn(s_user.User,only_query='author_id')
owner_id = f.UUID(data_key='ownerID')
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
@ -54,4 +49,6 @@ class Lot(Thing):
name = SanitizedStr(validate=f.validate.Length(max=STR_SIZE), required=True)
description = SanitizedStr(description=m.Lot.description.comment)
trade = f.Nested(s_action.Trade, dump_only=True, only=TRADE_VALUES)
documents = f.Nested('TradeDocument', many=True, dump_only=True, only=DOCUMENTS_VALUES)
documents = f.Nested(
'TradeDocument', many=True, dump_only=True, only=DOCUMENTS_VALUES
)

View file

@ -9,8 +9,6 @@ from marshmallow import Schema as MarshmallowSchema
from marshmallow import fields as f
from sqlalchemy import or_
from sqlalchemy.util import OrderedSet
from teal.marshmallow import EnumField
from teal.resource import View
from ereuse_devicehub.db import db
from ereuse_devicehub.inventory.models import Transfer
@ -18,6 +16,8 @@ from ereuse_devicehub.query import things_response
from ereuse_devicehub.resources.action.models import Confirm, Revoke, Trade
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
from ereuse_devicehub.resources.lot.models import Lot, Path
from ereuse_devicehub.teal.marshmallow import EnumField
from ereuse_devicehub.teal.resource import View
class LotFormat(Enum):
@ -79,7 +79,7 @@ class LotView(View):
lot = Lot.query.filter_by(id=id).one() # type: Lot
return self.schema.jsonify(lot, nested=2)
# @teal.cache.cache(datetime.timedelta(minutes=5))
# @ereuse_devicehub.teal.cache.cache(datetime.timedelta(minutes=5))
def find(self, args: dict):
"""Gets lots.

View file

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

View file

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

View file

@ -1,31 +1,38 @@
from flask import request, g, jsonify
from contextlib import suppress
from teal.resource import View
from flask import g, jsonify, request
from ereuse_devicehub.resources.action import schemas
from ereuse_devicehub.resources.action.models import Allocate, Live, Action, ToRepair, ToPrepare
from ereuse_devicehub.resources.action.models import (
Action,
Allocate,
Live,
ToPrepare,
ToRepair,
)
from ereuse_devicehub.resources.device import models as m
from ereuse_devicehub.resources.metric.schema import Metric
from ereuse_devicehub.teal.resource import View
class MetricsView(View):
def find(self, args: dict):
metrics = {
"allocateds": self.allocated(),
"live": self.live(),
"allocateds": self.allocated(),
"live": self.live(),
}
return jsonify(metrics)
def allocated(self):
# TODO @cayop we need uncomment when the pr/83 is approved
# return m.Device.query.filter(m.Device.allocated==True, owner==g.user).count()
return m.Device.query.filter(m.Device.allocated==True).count()
return m.Device.query.filter(m.Device.allocated == True).count()
def live(self):
# TODO @cayop we need uncomment when the pr/83 is approved
# devices = m.Device.query.filter(m.Device.allocated==True, owner==g.user)
devices = m.Device.query.filter(m.Device.allocated==True)
devices = m.Device.query.filter(m.Device.allocated == True)
count = 0
for dev in devices:
live = allocate = None
@ -41,4 +48,3 @@ class MetricsView(View):
count += 1
return count

View file

@ -1,4 +1,5 @@
from datetime import datetime, timezone
from flask_sqlalchemy import event
from ereuse_devicehub.db import db
@ -16,18 +17,23 @@ class Thing(db.Model):
`schema.org's Thing class <https://schema.org/Thing>`_
using only needed fields.
"""
__abstract__ = True
updated = db.Column(db.TIMESTAMP(timezone=True),
nullable=False,
index=True,
server_default=db.text('CURRENT_TIMESTAMP'))
updated = db.Column(
db.TIMESTAMP(timezone=True),
nullable=False,
index=True,
server_default=db.text('CURRENT_TIMESTAMP'),
)
updated.comment = """The last time Devicehub recorded a change for
this thing.
"""
created = db.Column(db.TIMESTAMP(timezone=True),
nullable=False,
index=True,
server_default=db.text('CURRENT_TIMESTAMP'))
created = db.Column(
db.TIMESTAMP(timezone=True),
nullable=False,
index=True,
server_default=db.text('CURRENT_TIMESTAMP'),
)
created.comment = """When Devicehub created this."""
def __init__(self, **kwargs) -> None:
@ -36,11 +42,15 @@ class Thing(db.Model):
self.created = kwargs.get('created', datetime.now(timezone.utc))
super().__init__(**kwargs)
def delete(self):
db.session.delete(self)
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)
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)

View file

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

View file

@ -2,15 +2,19 @@ import csv
import pathlib
from click import argument, option
from ereuse_utils import cli
from teal.resource import Converters, Resource
from teal.teal import Teal
from ereuse_devicehub.ereuse_utils import cli
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.definitions import DeviceDef
from ereuse_devicehub.resources.tag import schema
from ereuse_devicehub.resources.tag.model import Tag
from ereuse_devicehub.resources.tag.view import TagDeviceView, TagView, get_device_from_tag
from ereuse_devicehub.resources.tag.view import (
TagDeviceView,
TagView,
get_device_from_tag,
)
from ereuse_devicehub.teal.resource import Converters, Resource
from ereuse_devicehub.teal.teal import Teal
class TagDef(Resource):
@ -25,48 +29,77 @@ class TagDef(Resource):
'By default set to the actual Devicehub.'
CLI_SCHEMA = schema.Tag(only=('id', 'provider', 'org', 'secondary'))
def __init__(self, app: Teal, import_name=__name__.split('.')[0], static_folder=None,
static_url_path=None,
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None):
cli_commands = (
(self.create_tag, 'add'),
(self.create_tags_csv, 'add-csv')
def __init__(
self,
app: Teal,
import_name=__name__.split('.')[0],
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
):
cli_commands = ((self.create_tag, 'add'), (self.create_tags_csv, 'add-csv'))
super().__init__(
app,
import_name,
static_folder,
static_url_path,
template_folder,
url_prefix,
subdomain,
url_defaults,
root_path,
cli_commands,
)
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
# DeviceTagView URLs
device_view = TagDeviceView.as_view('tag-device-view', definition=self, auth=app.auth)
device_view = TagDeviceView.as_view(
'tag-device-view', definition=self, auth=app.auth
)
if self.AUTH:
device_view = app.auth.requires_auth(device_view)
self.add_url_rule('/<{0.ID_CONVERTER.value}:{0.ID_NAME}>/device'.format(self),
view_func=device_view,
methods={'GET'})
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
view_func=device_view,
methods={'PUT'})
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
view_func=device_view,
methods={'DELETE'})
self.add_url_rule(
'/<{0.ID_CONVERTER.value}:{0.ID_NAME}>/device'.format(self),
view_func=device_view,
methods={'GET'},
)
self.add_url_rule(
'/<{0.ID_CONVERTER.value}:tag_id>/'.format(self)
+ 'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
view_func=device_view,
methods={'PUT'},
)
self.add_url_rule(
'/<{0.ID_CONVERTER.value}:tag_id>/'.format(self)
+ 'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
view_func=device_view,
methods={'DELETE'},
)
@option('-u', '--owner', help=OWNER_H)
@option('-o', '--org', help=ORG_H)
@option('-p', '--provider', help=PROV_H)
@option('-s', '--sec', help=Tag.secondary.comment)
@argument('id')
def create_tag(self,
id: str,
org: str = None,
owner: str = None,
sec: str = None,
provider: str = None):
def create_tag(
self,
id: str,
org: str = None,
owner: str = None,
sec: str = None,
provider: str = None,
):
"""Create a tag with the given ID."""
db.session.add(Tag(**self.schema.load(
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
)))
db.session.add(
Tag(
**self.schema.load(
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
)
)
)
db.session.commit()
@option('-u', '--owner', help=OWNER_H)
@ -83,7 +116,17 @@ class TagDef(Resource):
"""
with path.open() as f:
for id, sec in csv.reader(f):
db.session.add(Tag(**self.schema.load(
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
)))
db.session.add(
Tag(
**self.schema.load(
dict(
id=id,
owner=owner,
org=org,
secondary=sec,
provider=provider,
)
)
)
)
db.session.commit()

View file

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

View file

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

View file

@ -1,14 +1,16 @@
from flask import Response, current_app as app, g, redirect, request
from flask import Response
from flask import current_app as app
from flask import g, redirect, request
from flask_sqlalchemy import Pagination
from teal.marshmallow import ValidationError
from teal.resource import View, url_for_resource
from ereuse_devicehub import auth
from ereuse_devicehub.db import db
from ereuse_devicehub.query import things_response
from ereuse_devicehub.resources.utils import hashcode
from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.resources.tag import Tag
from ereuse_devicehub.resources.utils import hashcode
from ereuse_devicehub.teal.marshmallow import ValidationError
from ereuse_devicehub.teal.resource import View, url_for_resource
class TagView(View):
@ -34,13 +36,19 @@ class TagView(View):
@auth.Auth.requires_auth
def find(self, args: dict):
tags = Tag.query.filter(Tag.is_printable_q()) \
.filter_by(owner=g.user) \
.order_by(Tag.created.desc()) \
.paginate(per_page=200) # type: Pagination
tags = (
Tag.query.filter(Tag.is_printable_q())
.filter_by(owner=g.user)
.order_by(Tag.created.desc())
.paginate(per_page=200)
) # type: Pagination
return things_response(
self.schema.dump(tags.items, many=True, nested=0),
tags.page, tags.per_page, tags.total, tags.prev_num, tags.next_num
tags.page,
tags.per_page,
tags.total,
tags.prev_num,
tags.next_num,
)
def _create_many_regular_tags(self, num: int):
@ -48,7 +56,9 @@ class TagView(View):
tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id]
db.session.add_all(tags)
db.session().final_flush()
response = things_response(self.schema.dump(tags, many=True, nested=1), code=201)
response = things_response(
self.schema.dump(tags, many=True, nested=1), code=201
)
db.session.commit()
return response

View file

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

View file

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

View file

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

View file

@ -1,18 +1,20 @@
import os
import time
from datetime import datetime
from flask import current_app as app, request, g, Response
from flask import Response
from flask import current_app as app
from flask import g, request
from marshmallow import ValidationError
from teal.resource import View
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
from ereuse_devicehub.resources.action.models import ConfirmDocument
from ereuse_devicehub.resources.hash_reports import ReportHash
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
from ereuse_devicehub.teal.resource import View
class TradeDocumentView(View):
def one(self, id: str):
doc = TradeDocument.query.filter_by(id=id, owner=g.user).one()
return self.schema.jsonify(doc)
@ -33,10 +35,9 @@ class TradeDocumentView(View):
trade = doc.lot.trade
if trade:
trade.documents.add(doc)
confirm = ConfirmDocument(action=trade,
user=g.user,
devices=set(),
documents={doc})
confirm = ConfirmDocument(
action=trade, user=g.user, devices=set(), documents={doc}
)
db.session.add(confirm)
db.session.add(doc)
db.session().final_flush()

View file

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

View file

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

View file

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

View file

@ -2,11 +2,11 @@ from uuid import UUID, uuid4
from flask import g, request
from flask.json import jsonify
from teal.resource import View
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.teal.resource import View
class UserView(View):
@ -19,7 +19,9 @@ def login():
user_s = g.resource_def.SCHEMA(only=('email', 'password')) # type: UserS
# noinspection PyArgumentList
u = request.get_json(schema=user_s)
user = User.query.filter_by(email=u['email'], active=True, phantom=False).one_or_none()
user = User.query.filter_by(
email=u['email'], active=True, phantom=False
).one_or_none()
if user and user.password == u['password']:
schema_with_token = g.resource_def.SCHEMA(exclude=set())
return schema_with_token.jsonify(user)

View file

@ -1,16 +1,16 @@
import flask
import json
import requests
import teal.marshmallow
from typing import Callable, Iterable, Tuple
from urllib.parse import urlparse
from flask import make_response, g
from flask.json import jsonify
from teal.resource import Resource, View
from ereuse_devicehub.resources.inventory.model import Inventory
import flask
import requests
from flask import g, make_response
from flask.json import jsonify
import ereuse_devicehub.teal.marshmallow
from ereuse_devicehub import __version__
from ereuse_devicehub.resources.inventory.model import Inventory
from ereuse_devicehub.teal.resource import Resource, View
def get_tag_version(app):
@ -29,6 +29,7 @@ def get_tag_version(app):
else:
return {}
class VersionView(View):
def get(self, *args, **kwargs):
"""Get version of DeviceHub and ereuse-tag."""
@ -48,18 +49,31 @@ class VersionDef(Resource):
VIEW = None # We do not want to create default / documents endpoint
AUTH = False
def __init__(self, app,
import_name=__name__,
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
def __init__(
self,
app,
import_name=__name__,
static_folder=None,
static_url_path=None,
template_folder=None,
url_prefix=None,
subdomain=None,
url_defaults=None,
root_path=None,
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
):
super().__init__(
app,
import_name,
static_folder,
static_url_path,
template_folder,
url_prefix,
subdomain,
url_defaults,
root_path,
cli_commands,
)
d = {'devicehub': __version__, "ereuse_tag": "0.0.0"}
get = {'GET'}

View file

@ -30,6 +30,10 @@ $(document).ready(() => {
;
select_shift(); // $('#selectLot').selectpicker();
$("#filter").on("change", () => {
$("#submit_filter").click();
});
});
class TableController {
@ -211,8 +215,8 @@ function removeLot() {
}
function select_shift() {
const chkboxes = $('.deviceSelect');
var lastChecked = null;
const chkboxes = $(".deviceSelect");
let lastChecked = null;
chkboxes.click(function (e) {
if (!lastChecked) {
lastChecked = this;
@ -324,17 +328,16 @@ function export_file(type_file) {
function export_actions_erasure(type_file) {
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) {
const url = `/inventory/export/${type_file}/?ids=${actions_id}`;
const url = "/inventory/export/".concat(type_file, "/?ids=").concat(actions_id);
window.location.href = url;
} else {
$("#exportAlertModal").click();
}
}
class lotsSearcher {
static enable() {
if (this.lotsSearchElement) this.lotsSearchElement.disabled = false;
@ -663,19 +666,14 @@ async function processSelectedDevices() {
return lot;
});
listHTML.html("");
const lot_temporary = lots.filter(lot => !lot.transfer && !lot.trade);
appendMenu(lot_temporary, listHTML, templateLot, selectedDevices, actions, "Temporary");
const lot_incoming = lots.filter(lot => lot.transfer && lot.transfer == "Incoming");
appendMenu(lot_incoming, listHTML, templateLot, selectedDevices, actions, "Incoming");
const lot_outgoing = lots.filter(lot => lot.transfer && lot.transfer == "Outgoing");
appendMenu(lot_outgoing, listHTML, templateLot, selectedDevices, actions, "Outgoing");
lotsSearcher.enable();
} catch (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>");
@ -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 = 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));
}

View file

@ -16,6 +16,9 @@ $(document).ready(() => {
};
select_shift();
// $('#selectLot').selectpicker();
$("#filter").on("change", () => {
$("#submit_filter").click();
});
})
class TableController {

View file

View file

@ -0,0 +1,93 @@
import base64
from functools import wraps
from typing import Callable
from flask import current_app, g, request
from werkzeug.datastructures import Authorization
from werkzeug.exceptions import Unauthorized
class Auth:
"""
Authentication handler for Teal.
To authenticate the user (perform login):
1. Set Resource.AUTH to True, or manually decorate the view with
@auth.requires_auth
2. Extend any subclass of this one (like TokenAuth).
3. Implement the authenticate method with the authentication logic.
For example, in TokenAuth here you get the user from the token.
5. Set in your teal the Auth class you have created so
teal can use it.
"""
API_DOCS = {
'type': 'http',
'description:': 'HTTP Basic scheme',
'name': 'Authorization',
'in': 'header',
'scheme': 'basic',
}
@classmethod
def requires_auth(cls, f: Callable):
"""
Decorate a view enforcing authentication (logged in user).
"""
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth:
raise Unauthorized('Provide proper authorization credentials')
current_app.auth.perform_auth(auth)
return f(*args, **kwargs)
return decorated
def perform_auth(self, auth: Authorization):
"""
Authenticate an user. This loads the user.
An exception (expected Unauthorized) is raised if
authentication failed.
"""
g.user = self.authenticate(auth.username, auth.password)
def authenticate(self, username: str, password: str) -> object:
"""
The authentication logic. The result of this method is
a user or a raised exception, like Werkzeug's Unauthorized,
if authentication failed.
:raise: Unauthorized Authentication failed.
:return: The user object.
"""
raise NotImplementedError()
class TokenAuth(Auth):
API_DOCS = Auth.API_DOCS.copy()
API_DOCS['description'] = 'Basic scheme with token.'
def authenticate(self, token: str, *args, **kw) -> object:
"""
The result of this method is
a user or a raised exception if authentication failed.
:raise: Unauthorized Authentication failed.
:return The user object.
"""
raise NotImplementedError()
@staticmethod
def encode(value: str):
"""Creates a suitable Token that can be sent to a client
and sent back.
"""
return base64.b64encode(str.encode(str(value) + ':')).decode()
@staticmethod
def decode(value: str):
"""Decodes a token generated by ``encode``."""
return base64.b64decode(value.encode()).decode()[:-1]

View file

@ -0,0 +1,28 @@
import datetime
from functools import wraps
from flask import 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

View file

@ -0,0 +1,13 @@
from flask.testing import FlaskCliRunner
class TealCliRunner(FlaskCliRunner):
"""The same as FlaskCliRunner but with invoke's
'catch_exceptions' as False.
"""
def invoke(self, *args, cli=None, **kwargs):
kwargs.setdefault('catch_exceptions', False)
r = super().invoke(cli, args, **kwargs)
assert r.exit_code == 0, 'CLI code {}: {}'.format(r.exit_code, r.output)
return r

View file

@ -0,0 +1,181 @@
from typing import Any, Iterable, Tuple, Type, Union
from boltons.urlutils import URL
from ereuse_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])

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

@ -0,0 +1,382 @@
import enum
import ipaddress
import re
import uuid
from distutils.version import StrictVersion
from typing import Any, Type, Union
from boltons.typeutils import classproperty
from boltons.urlutils import URL as BoltonsUrl
from ereuse_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
)

File diff suppressed because it is too large Load diff

View 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