Draft on lot

This commit is contained in:
Xavier Bustamante Talavera 2018-08-08 21:25:53 +02:00
parent bc433d80e9
commit f5d69070e6
14 changed files with 360 additions and 12 deletions

View file

@ -22,7 +22,7 @@ Devicehub is built with [Teal](https://github.com/bustawin/teal) and
The requirements are: The requirements are:
- Python 3.5.3 or higher. In debian 9 is `# apt install python3-pip`. - Python 3.5.3 or higher. In debian 9 is `# apt install python3-pip`.
- PostgreSQL 9.6 or higher. In debian 9 is `# apt install postgresql` - PostgreSQL 9.6 or higher. In debian 9 is `# apt install postgresql-contrib`
- passlib. In debian 9 is `# apt install python3-passlib`. - passlib. In debian 9 is `# apt install python3-passlib`.
Install Devicehub with *pip*: `pip3 install ereuse-devicehub -U --pre`. Install Devicehub with *pip*: `pip3 install ereuse-devicehub -U --pre`.
@ -39,6 +39,8 @@ postgres $ createdb devicehub # Create main database
postgres $ psql devicehub # Access to the database postgres $ psql devicehub # Access to the database
postgres $ CREATE USER dhub WITH PASSWORD 'ereuse'; # Create user devicehub uses to access db postgres $ CREATE USER dhub WITH PASSWORD 'ereuse'; # Create user devicehub uses to access db
postgres $ GRANT ALL PRIVILEGES ON DATABASE devicehub TO dhub; # Give access to the db postgres $ GRANT ALL PRIVILEGES ON DATABASE devicehub TO dhub; # Give access to the db
postgres $ CREATE EXTENSION pgcrypto SCHEMA public; # Enable pgcrypto
postgres $ CREATE EXTENSION ltree SCHEMA public; # Enable ltree
postgres $ \q postgres $ \q
exit exit
``` ```

View file

@ -2,7 +2,7 @@ from distutils.version import StrictVersion
from itertools import chain from itertools import chain
from typing import Set from typing import Set
from ereuse_devicehub.resources import agent, device, event, inventory, tag, user from ereuse_devicehub.resources import agent, device, event, inventory, lot, tag, user
from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware
from teal.auth import TokenAuth from teal.auth import TokenAuth
from teal.config import Config from teal.config import Config
@ -16,7 +16,8 @@ class DevicehubConfig(Config):
import_resource(user), import_resource(user),
import_resource(tag), import_resource(tag),
import_resource(inventory), import_resource(inventory),
import_resource(agent))) import_resource(agent),
import_resource(lot)))
PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str] PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str]
SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str
SCHEMA = 'dhub' SCHEMA = 'dhub'

View file

@ -1,7 +1,16 @@
from sqlalchemy.dialects import postgresql
from teal.db import SQLAlchemy as _SQLAlchemy from teal.db import SQLAlchemy as _SQLAlchemy
class SQLAlchemy(_SQLAlchemy): class SQLAlchemy(_SQLAlchemy):
"""
Superuser must create the required extensions in the public
schema of the database, as it is in the `search_path`
defined in teal.
"""
UUID = postgresql.UUID
def drop_all(self, bind='__all__', app=None): def drop_all(self, bind='__all__', app=None):
"""A faster nuke-like option to drop everything.""" """A faster nuke-like option to drop everything."""
self.drop_schema() self.drop_schema()

View file

@ -51,7 +51,7 @@ class Event(Thing):
incidence.comment = """ incidence.comment = """
Should this event be reviewed due some anomaly? Should this event be reviewed due some anomaly?
""" """
closed = Column(Boolean, default=True, nullable=False) closed = Column(Boolean, default=False, nullable=False)
closed.comment = """ closed.comment = """
Whether the author has finished the event. Whether the author has finished the event.
After this is set to True, no modifications are allowed. After this is set to True, no modifications are allowed.
@ -100,7 +100,7 @@ class Event(Thing):
author = relationship(User, author = relationship(User,
backref=backref('authored_events', lazy=True, collection_class=set), backref=backref('authored_events', lazy=True, collection_class=set),
primaryjoin=author_id == User.id) primaryjoin=author_id == User.id)
""" author_id.comment = """
The user that recorded this action in the system. The user that recorded this action in the system.
This does not necessarily has to be the person that produced This does not necessarily has to be the person that produced
@ -118,7 +118,7 @@ class Event(Thing):
lazy=True, lazy=True,
collection_class=OrderedSet, collection_class=OrderedSet,
order_by=lambda: Event.created), order_by=lambda: Event.created),
primaryjoin=agent_id == Agent.id, ) primaryjoin=agent_id == Agent.id)
agent_id.comment = """ agent_id.comment = """
The direct performer or driver of the action. e.g. John wrote a book. The direct performer or driver of the action. e.g. John wrote a book.

View file

@ -24,7 +24,7 @@ from teal.resource import Schema
class Event(Thing): class Event(Thing):
id = UUID(dump_only=True) id = UUID(dump_only=True)
name = String(default='', validate=Length(STR_BIG_SIZE), description=m.Event.name.comment) name = String(default='', validate=Length(max=STR_BIG_SIZE), description=m.Event.name.comment)
incidence = Boolean(default=False, description=m.Event.incidence.comment) incidence = Boolean(default=False, description=m.Event.incidence.comment)
closed = Boolean(missing=True, description=m.Event.closed.comment) closed = Boolean(missing=True, description=m.Event.closed.comment)
error = Boolean(default=False, description=m.Event.error.comment) error = Boolean(default=False, description=m.Event.error.comment)

View file

@ -0,0 +1,19 @@
import pathlib
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.lot import schemas
from ereuse_devicehub.resources.lot.views import LotView
from teal.resource import Converters, Resource
class LotDef(Resource):
SCHEMA = schemas.Lot
VIEW = LotView
AUTH = True
ID_CONVERTER = Converters.uuid
def init_db(self, db: 'db.SQLAlchemy'):
# Create functions
with pathlib.Path(__file__).parent.joinpath('dag.sql').open() as f:
sql = f.read()
db.session.execute(sql)

View file

@ -0,0 +1,110 @@
CREATE OR REPLACE FUNCTION add_edge(parent_id uuid, child_id uuid)
/* Adds an edge between ``parent`` and ``child``.
Designed to work with Directed Acyclic Graphs (DAG)
(or said in another way, trees with multiple parents without cycles).
This method will raise an exception if:
- Parent is the same as child.
- Child contains the parent.
- Edge parent - child already exists.
Influenced by:
- https://www.codeproject.com/Articles/22824/A-Model-to-Represent-Directed-Acyclic-Graphs-DAG
- http://patshaughnessy.net/2017/12/12/installing-the-postgres-ltree-extension
- https://en.wikipedia.org/wiki/Directed_acyclic_graph
*/
RETURNS void AS $$
DECLARE
parent text := replace(CAST(parent_id as text), '-', '_');
child text := replace(CAST(child_id as text), '-', '_');
BEGIN
if parent = child
then
raise exception 'Cannot create edge: the parent is the same as the child.';
end if;
if exists(
select 1 from edge where edge.path ~ CAST('*.' || child || '.*.' || parent || '.*' as lquery)
)
then
raise exception 'Cannot create edge: child already contains parent.';
end if;
-- We have two subgraphs: the parent subgraph that goes from the parent to the root,
-- and the child subgraph, going from the child (which is the root of this subgraph)
-- to all the leafs.
-- We do the cartesian product from all the paths of the parent subgraph that end in the parent
-- WITH all the paths that start from the child that end to its leafs.
insert into edge (lot_id, path) (select distinct lot_id, fp.path ||
subpath(edge.path, index(edge.path, text2ltree(child)))
from edge,
(select path
from edge
where path ~ CAST('*.' || parent AS lquery)) as fp
where edge.path ~ CAST('*.' || child || '.*' AS lquery));
-- Cleanup: old paths that start with the child (that where used above in the cartesian product)
-- have became a subset of the result of the cartesian product, thus being redundant.
delete from edge where edge.path ~ CAST(child || '.*' AS lquery);
END
$$
LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION delete_edge(parent_id uuid, child_id uuid)
/* Deletes an edge between ``parent`` and ``child``.
Designed to work with DAG (See ``add_edge`` function).
This method will raise an exception if the relationship does not
exist.
*/
RETURNS void AS $$
DECLARE
parent text := replace(CAST(parent_id as text), '-', '_');
child text := replace(CAST(child_id as text), '-', '_');
number int;
BEGIN
-- to delete we remove from the path of the descendants of the child
-- (and the child) any ancestor coming from this edge.
-- When we added the edge we did a cartesian product. When removing
-- this part of the path we will have duplicate paths.
-- don't check uniqueness for path key until we delete duplicates
SET CONSTRAINTS edge_path_unique DEFERRED;
-- remove everything above the child lot_id in the path
-- this creates duplicates on path and lot_id
update edge
set path = subpath(path, index(path, text2ltree(child)))
where path ~ CAST('*.' || parent || '.' || child || '.*' AS lquery);
-- remove duplicates
-- we need an id field exclusively for this operation
-- from https://wiki.postgresql.org/wiki/Deleting_duplicates
DELETE
FROM edge
WHERE id IN (SELECT id
FROM (SELECT id, ROW_NUMBER() OVER (partition BY lot_id, path) AS rnum FROM edge) t
WHERE t.rnum > 1);
-- re-activate uniqueness check and perform check
SET CONSTRAINTS edge_path_unique IMMEDIATE;
-- After the update the one of the paths of the child will be
-- containing only the child.
-- This can only be when the child has no parent at all.
-- In case the child has more than one parent, remove this path
-- (note that we want it to remove it too from descendants of this
-- child, ex. 'child_id'.'desc1')
select COUNT(1) into number from edge where lot_id = child_id;
IF number > 1
THEN
delete from edge where path <@ text2ltree(child);
end if;
END
$$
LANGUAGE plpgsql;

View file

@ -0,0 +1,101 @@
import uuid
from datetime import datetime
from typing import Set
from flask import g
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import expression
from sqlalchemy_utils import LtreeType
from sqlalchemy_utils.types.ltree import LQUERY
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.resources.models import STR_SIZE, Thing
from ereuse_devicehub.resources.user.models import User
class Lot(Thing):
id = db.Column(UUID(as_uuid=True),
primary_key=True,
server_default=db.text('gen_random_uuid()'))
name = db.Column(db.Unicode(STR_SIZE), nullable=False)
closed = db.Column(db.Boolean, default=False, nullable=False)
closed.comment = """
A closed lot cannot be modified anymore.
"""
devices = db.relationship(Device,
backref=db.backref('parents', lazy=True, collection_class=set),
secondary=lambda: LotDevice.__table__,
collection_class=set)
def __repr__(self) -> str:
return '<Lot {0.name} devices={0.devices!r}>'.format(self)
def add_child(self, child: 'Lot'):
"""Adds a child to this lot."""
Edge.add(self.id, child.id)
db.session.refresh(self) # todo is this useful?
db.session.refresh(child)
def remove_child(self, child: 'Lot'):
Edge.delete(self.id, child.id)
def __contains__(self, child: 'Lot'):
return Edge.has_lot(self.id, child.id)
class LotDevice(db.Model):
device_id = db.Column(db.BigInteger, db.ForeignKey(Device.id), primary_key=True)
lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), primary_key=True)
created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
author_id = db.Column(UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id)
author = db.relationship(User, primaryjoin=author_id == User.id)
author_id.comment = """
The user that put the device in the lot.
"""
class Edge(Thing):
id = db.Column(db.UUID(as_uuid=True),
primary_key=True,
server_default=db.text('gen_random_uuid()'))
lot_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
lot = db.relationship(Lot,
backref=db.backref('edges', lazy=True, collection_class=set),
primaryjoin=Lot.id == lot_id)
path = db.Column(LtreeType, unique=True, nullable=False)
__table_args__ = (
db.UniqueConstraint(path, name='edge_path_unique', deferrable=True, initially='immediate'),
db.Index('path_gist', path, postgresql_using='gist'),
db.Index('path_btree', path, postgresql_using='btree')
)
def children(self) -> Set['Edge']:
"""Get the children edges."""
# From https://stackoverflow.com/a/41158890
exp = '*.{}.*{{1}}'.format(self.lot_id)
return set(self.query
.filter(self.path.lquery(expression.cast(exp, LQUERY)))
.distinct(self.__class__.lot_id)
.all())
@classmethod
def add(cls, parent_id: uuid.UUID, child_id: uuid.UUID):
"""Creates an edge between parent and child."""
db.session.execute(db.func.add_edge(str(parent_id), str(child_id)))
@classmethod
def delete(cls, parent_id: uuid.UUID, child_id: uuid.UUID):
"""Deletes the edge between parent and child."""
db.session.execute(db.func.delete_edge(str(parent_id), str(child_id)))
@classmethod
def has_lot(cls, parent_id: uuid.UUID, child_id: uuid.UUID) -> bool:
return bool(db.session.execute(
"SELECT 1 from edge where path ~ '*.{}.*.{}.*'".format(
str(parent_id).replace('-', '_'), str(child_id).replace('-', '_'))
).first())

View file

@ -0,0 +1,14 @@
from marshmallow import fields as f
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.device.schemas import Device
from ereuse_devicehub.resources.lot import models as m
from ereuse_devicehub.resources.models import STR_SIZE
from ereuse_devicehub.resources.schemas import Thing
class Lot(Thing):
id = f.UUID(dump_only=True)
name = f.String(validate=f.validate.Length(max=STR_SIZE))
closed = f.String(required=True, missing=False, description=m.Lot.closed.comment)
devices = f.String(NestedOn(Device, many=True, collection_class=set, only_query='id'))

View file

@ -0,0 +1,25 @@
import uuid
from flask import current_app as app, request
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.lot.models import Lot
from teal.resource import View
class LotView(View):
def post(self):
json = request.get_json(validate=False)
e = app.resources[json['type']].schema.load(json)
Model = db.Model._decl_class_registry.data[json['type']]()
lot = Model(**e)
db.session.add(lot)
db.session.commit()
ret = self.schema.jsonify(lot)
ret.status_code = 201
return ret
def one(self, id: uuid.UUID):
"""Gets one event."""
event = Lot.query.filter_by(id=id).one()
return self.schema.jsonify(event)

View file

@ -38,7 +38,7 @@ class Tag(Thing):
return value return value
__table_args__ = ( __table_args__ = (
UniqueConstraint(device_id, org_id, name='One tag per organization.'), UniqueConstraint(device_id, org_id, name='one_tag_per_organization'),
) )
def __repr__(self) -> str: def __repr__(self) -> str:

View file

@ -24,7 +24,8 @@ def test_api_docs(client: Client):
'/tags/', '/tags/',
'/snapshots/', '/snapshots/',
'/users/login', '/users/login',
'/events/' '/events/',
'/lots/'
} }
assert docs['info'] == {'title': 'Devicehub', 'version': '0.2'} assert docs['info'] == {'title': 'Devicehub', 'version': '0.2'}
assert docs['components']['securitySchemes']['bearerAuth'] == { assert docs['components']['securitySchemes']['bearerAuth'] == {
@ -35,4 +36,4 @@ def test_api_docs(client: Client):
'scheme': 'basic', 'scheme': 'basic',
'name': 'Authorization' 'name': 'Authorization'
} }
assert 76 == len(docs['definitions']) assert 77 == len(docs['definitions'])

65
tests/test_lot.py Normal file
View file

@ -0,0 +1,65 @@
import pytest
from flask import g
from sqlalchemy_utils import Ltree
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.models import Desktop
from ereuse_devicehub.resources.enums import ComputerChassis
from ereuse_devicehub.resources.lot.models import Edge, Lot, LotDevice
from tests import conftest
@pytest.mark.usefixtures(conftest.auth_app_context.__name__)
def test_lot_device_relationship():
device = Desktop(serial_number='foo',
model='bar',
manufacturer='foobar',
chassis=ComputerChassis.Lunchbox)
lot = Lot(name='lot1')
lot.devices.add(device)
db.session.add(lot)
db.session.flush()
lot_device = LotDevice.query.one() # type: LotDevice
assert lot_device.device_id == device.id
assert lot_device.lot_id == lot.id
assert lot_device.created
assert lot_device.author_id == g.user.id
assert device.parents == {lot}
@pytest.mark.usefixtures(conftest.auth_app_context.__name__)
def test_add_edge():
child = Lot(name='child')
parent = Lot(name='parent')
db.session.add(child)
db.session.add(parent)
db.session.flush()
# todo edges should automatically be created when the lot is created
child.edges.add(Edge(path=Ltree(str(child.id).replace('-', '_'))))
parent.edges.add(Edge(path=Ltree(str(parent.id).replace('-', '_'))))
db.session.flush()
parent.add_child(child)
assert child in parent
assert len(child.edges) == 1
assert len(parent.edges) == 1
parent.remove_child(child)
assert child not in parent
assert len(child.edges) == 1
assert len(parent.edges) == 1
grandparent = Lot(name='grandparent')
db.session.add(grandparent)
db.session.flush()
grandparent.edges.add(Edge(path=Ltree(str(grandparent.id).replace('-', '_'))))
db.session.flush()
grandparent.add_child(parent)
parent.add_child(child)
assert parent in grandparent
assert child in parent
assert child in grandparent

View file

@ -8,9 +8,10 @@ from ereuse_devicehub.resources.enums import Bios, ComputerChassis, ImageMimeTyp
RatingSoftware RatingSoftware
from ereuse_devicehub.resources.event.models import PhotoboxRate, WorkbenchRate from ereuse_devicehub.resources.event.models import PhotoboxRate, WorkbenchRate
from ereuse_devicehub.resources.image.models import Image, ImageList from ereuse_devicehub.resources.image.models import Image, ImageList
from tests import conftest
@pytest.mark.usefixtures('auth_app_context') @pytest.mark.usefixtures(conftest.auth_app_context.__name__)
def test_workbench_rate_db(): def test_workbench_rate_db():
rate = WorkbenchRate(processor=0.1, rate = WorkbenchRate(processor=0.1,
ram=1.0, ram=1.0,
@ -25,7 +26,7 @@ def test_workbench_rate_db():
db.session.commit() db.session.commit()
@pytest.mark.usefixtures('auth_app_context') @pytest.mark.usefixtures(conftest.auth_app_context.__name__)
def test_photobox_rate_db(): def test_photobox_rate_db():
pc = Desktop(serial_number='24', chassis=ComputerChassis.Tower) pc = Desktop(serial_number='24', chassis=ComputerChassis.Tower)
image = Image(name='foo', image = Image(name='foo',