diff --git a/ereuse_devicehub/config.py b/ereuse_devicehub/config.py index 58176223..9f05c29d 100644 --- a/ereuse_devicehub/config.py +++ b/ereuse_devicehub/config.py @@ -12,6 +12,7 @@ from ereuse_devicehub.resources import action, agent, deliverynote, inventory, \ lot, tag, user from ereuse_devicehub.resources.device import definitions from ereuse_devicehub.resources.documents import documents +from ereuse_devicehub.resources.tradedocument import definitions as tradedocument from ereuse_devicehub.resources.enums import PriceSoftware from ereuse_devicehub.resources.versions import versions from ereuse_devicehub.resources.licences import licences @@ -27,6 +28,7 @@ class DevicehubConfig(Config): import_resource(lot), import_resource(deliverynote), import_resource(documents), + import_resource(tradedocument), import_resource(inventory), import_resource(versions), import_resource(licences), @@ -71,3 +73,6 @@ class DevicehubConfig(Config): """Admin email""" EMAIL_ADMIN = config('EMAIL_ADMIN', '') + + """Definition of path where save the documents of customers""" + PATH_DOCUMENTS_STORAGE = config('PATH_DOCUMENTS_STORAGE', '/tmp/') diff --git a/ereuse_devicehub/migrations/versions/3a3601ac8224_tradedocuments.py b/ereuse_devicehub/migrations/versions/3a3601ac8224_tradedocuments.py new file mode 100644 index 00000000..19e65087 --- /dev/null +++ b/ereuse_devicehub/migrations/versions/3a3601ac8224_tradedocuments.py @@ -0,0 +1,134 @@ +"""tradeDocuments + +Revision ID: 3a3601ac8224 +Revises: 51439cf24be8 +Create Date: 2021-06-15 14:38:59.931818 + +""" +import teal +import citext +import sqlalchemy as sa +from alembic import op +from alembic import context +from sqlalchemy.dialects import postgresql + + + +# revision identifiers, used by Alembic. +revision = '3a3601ac8224' +down_revision = '51439cf24be8' +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('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_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), + + 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()}') + diff --git a/ereuse_devicehub/migrations/versions/51439cf24be8_change_trade_action.py b/ereuse_devicehub/migrations/versions/51439cf24be8_change_trade_action.py index 5bf3ef54..778f484b 100644 --- a/ereuse_devicehub/migrations/versions/51439cf24be8_change_trade_action.py +++ b/ereuse_devicehub/migrations/versions/51439cf24be8_change_trade_action.py @@ -5,11 +5,12 @@ Revises: eca457d8b2a4 Create Date: 2021-03-15 17:40:34.410408 """ +import sqlalchemy as sa +import citext +import teal from alembic import op from alembic import context from sqlalchemy.dialects import postgresql -import sqlalchemy as sa -import citext # revision identifiers, used by Alembic. @@ -83,7 +84,7 @@ def upgrade(): schema=f'{get_inv()}' ) - # ## User + ## User op.add_column('user', sa.Column('active', sa.Boolean(), default=True, nullable=True), schema='common') op.add_column('user', sa.Column('phantom', sa.Boolean(), default=False, nullable=True), diff --git a/ereuse_devicehub/resources/action/__init__.py b/ereuse_devicehub/resources/action/__init__.py index 368b4528..5e581cd5 100644 --- a/ereuse_devicehub/resources/action/__init__.py +++ b/ereuse_devicehub/resources/action/__init__.py @@ -270,6 +270,21 @@ class TradeDef(ActionDef): SCHEMA = schemas.Trade +class ConfirmDocumentDef(ActionDef): + VIEW = None + SCHEMA = schemas.ConfirmDocument + + +class RevokeDocumentDef(ActionDef): + VIEW = None + SCHEMA = schemas.RevokeDocument + + +class ConfirmRevokeDocumentDef(ActionDef): + VIEW = None + SCHEMA = schemas.ConfirmRevokeDocument + + class CancelTradeDef(ActionDef): VIEW = None SCHEMA = schemas.CancelTrade diff --git a/ereuse_devicehub/resources/action/models.py b/ereuse_devicehub/resources/action/models.py index 2fc8a803..d8bcb3ce 100644 --- a/ereuse_devicehub/resources/action/models.py +++ b/ereuse_devicehub/resources/action/models.py @@ -48,6 +48,7 @@ from ereuse_devicehub.resources.enums import AppearanceRange, BatteryHealth, Bio TestDataStorageLength from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing from ereuse_devicehub.resources.user.models import User +from ereuse_devicehub.resources.tradedocument.models import TradeDocument class JoinedTableMixin: @@ -295,6 +296,20 @@ class ActionDevice(db.Model): primary_key=True) +class ActionWithMultipleTradeDocuments(ActionWithMultipleDevices): + documents = relationship(TradeDocument, + backref=backref('actions_docs', lazy=True, **_sorted_actions), + secondary=lambda: ActionTradeDocument.__table__, + order_by=lambda: TradeDocument.id, + collection_class=OrderedSet) + + +class ActionTradeDocument(db.Model): + document_id = Column(BigInteger, ForeignKey(TradeDocument.id), primary_key=True) + action_id = Column(UUID(as_uuid=True), ForeignKey(ActionWithMultipleTradeDocuments.id), + primary_key=True) + + class Add(ActionWithOneDevice): """The act of adding components to a device. @@ -1433,6 +1448,43 @@ class CancelReservation(Organize): """The act of cancelling a reservation.""" +class ConfirmDocument(JoinedTableMixin, ActionWithMultipleTradeDocuments): + """Users confirm the one action trade this confirmation it's link to trade + and the document that confirm + """ + user_id = db.Column(UUID(as_uuid=True), + db.ForeignKey(User.id), + nullable=False, + default=lambda: g.user.id) + user = db.relationship(User, primaryjoin=user_id == User.id) + user_comment = """The user that accept the offer.""" + action_id = db.Column(UUID(as_uuid=True), + db.ForeignKey('action.id'), + nullable=False) + action = db.relationship('Action', + backref=backref('acceptances_document', + uselist=True, + lazy=True, + order_by=lambda: Action.end_time, + collection_class=list), + primaryjoin='ConfirmDocument.action_id == Action.id') + + def __repr__(self) -> str: + if self.action.t in ['Trade']: + origin = 'To' + if self.user == self.action.user_from: + origin = 'From' + return '<{0.t}app/views/inventory/ {0.id} accepted by {1}>'.format(self, origin) + + +class RevokeDocument(ConfirmDocument): + pass + + +class ConfirmRevokeDocument(ConfirmDocument): + pass + + class Confirm(JoinedTableMixin, ActionWithMultipleDevices): """Users confirm the one action trade this confirmation it's link to trade and the devices that confirm @@ -1473,7 +1525,7 @@ class ConfirmRevoke(Confirm): return '<{0.t} {0.id} accepted by {0.user}>'.format(self) -class Trade(JoinedTableMixin, ActionWithMultipleDevices): +class Trade(JoinedTableMixin, ActionWithMultipleTradeDocuments): """Trade actions log the political exchange of devices between users. Every time a trade action is performed, the old user looses its political possession, for example ownership, in favor of another @@ -1500,8 +1552,6 @@ class Trade(JoinedTableMixin, ActionWithMultipleDevices): currency = Column(DBEnum(Currency), nullable=False, default=Currency.EUR.name) currency.comment = """The currency of this price as for ISO 4217.""" date = Column(db.TIMESTAMP(timezone=True)) - document_id = Column(CIText()) - document_id.comment = """The id of one document like invoice so they can be linked.""" confirm = Column(Boolean, default=False, nullable=False) confirm.comment = """If you need confirmation of the user, you need actevate this field""" code = Column(CIText(), nullable=True) diff --git a/ereuse_devicehub/resources/action/schemas.py b/ereuse_devicehub/resources/action/schemas.py index fc168814..37b0a8a0 100644 --- a/ereuse_devicehub/resources/action/schemas.py +++ b/ereuse_devicehub/resources/action/schemas.py @@ -16,6 +16,7 @@ from ereuse_devicehub.resources import enums from ereuse_devicehub.resources.action import models as m from ereuse_devicehub.resources.agent import schemas as s_agent from ereuse_devicehub.resources.device import schemas as s_device +from ereuse_devicehub.resources.tradedocument import schemas as s_document from ereuse_devicehub.resources.enums import AppearanceRange, BiosAccessRange, FunctionalityRange, \ PhysicalErasureMethod, R_POSITIVE, RatingRange, \ Severity, SnapshotSoftware, TestDataStorageLength @@ -58,6 +59,15 @@ class ActionWithOneDevice(Action): device = NestedOn(s_device.Device, only_query='id') +class ActionWithMultipleDocuments(Action): + __doc__ = m.ActionWithMultipleTradeDocuments.__doc__ + documents = NestedOn(s_document.TradeDocument, + many=True, + required=True, # todo test ensuring len(devices) >= 1 + only_query='id', + collection_class=OrderedSet) + + class ActionWithMultipleDevices(Action): __doc__ = m.ActionWithMultipleDevices.__doc__ devices = NestedOn(s_device.Device, @@ -482,6 +492,126 @@ class Revoke(ActionWithMultipleDevices): txt = "Device {} not exist in the trade".format(dev.devicehub_id) raise ValidationError(txt) + for doc in data.get('documents', []): + # if document not exist in the Trade, then this query is wrong + if not doc in data['action'].documents: + txt = "Document {} not exist in the trade".format(doc.file_name) + raise ValidationError(txt) + + @validates_schema + def validate_documents(self, data): + """Check if there are or no one before confirmation, + This is not checked in the view becouse the list of documents is inmutable + + """ + if not data['devices'] == OrderedSet(): + return + + documents = [] + for doc in data['documents']: + actions = copy.copy(doc.actions) + actions.reverse() + for ac in actions: + if ac == data['action']: + # data['action'] is a Trade action, if this is the first action + # to find mean that this document don't have a confirmation + break + + if ac.t == 'Revoke' and ac.user == g.user: + # this doc is confirmation jet + break + + if ac.t == Confirm.t and ac.user == g.user: + documents.append(doc) + break + + if not documents: + txt = 'No there are documents to revoke' + raise ValidationError(txt) + + +class ConfirmDocument(ActionWithMultipleDocuments): + __doc__ = m.Confirm.__doc__ + action = NestedOn('Action', only_query='id') + + @validates_schema + def validate_documents(self, data): + """If there are one device than have one confirmation, + then remove the list this device of the list of devices of this action + """ + # import pdb; pdb.set_trace() + if data['documents'] == OrderedSet(): + return + + for doc in data['documents']: + if not doc.lot.trade: + return + + data['action'] = doc.lot.trade + + if not doc.actions: + continue + + if not doc.trading == 'Need Confirmation': + txt = 'No there are documents to confirm' + raise ValidationError(txt) + + +class RevokeDocument(ActionWithMultipleDocuments): + __doc__ = m.RevokeDocument.__doc__ + action = NestedOn('Action', only_query='id') + + @validates_schema + def validate_documents(self, data): + """Check if there are or no one before confirmation, + This is not checked in the view becouse the list of documents is inmutable + + """ + # import pdb; pdb.set_trace() + if data['documents'] == OrderedSet(): + return + + for doc in data['documents']: + if not doc.lot.trade: + return + + data['action'] = doc.lot.trade + + if not doc.actions: + continue + + if not doc.trading in ['Document Confirmed', 'Confirm']: + txt = 'No there are documents to revoke' + raise ValidationError(txt) + + +class ConfirmRevokeDocument(ActionWithMultipleDocuments): + __doc__ = m.ConfirmRevoke.__doc__ + action = NestedOn('Action', only_query='id') + + @validates_schema + def validate_documents(self, data): + """Check if there are or no one before confirmation, + This is not checked in the view becouse the list of documents is inmutable + + """ + if data['documents'] == OrderedSet(): + return + + for doc in data['documents']: + if not doc.lot.trade: + return + + if not doc.actions: + continue + + + if not doc.trading == 'Revoke': + txt = 'No there are documents with revoke for confirm' + raise ValidationError(txt) + + data['action'] = doc.actions[-1] + class ConfirmRevoke(ActionWithMultipleDevices): __doc__ = m.ConfirmRevoke.__doc__ @@ -489,17 +619,62 @@ class ConfirmRevoke(ActionWithMultipleDevices): @validates_schema def validate_revoke(self, data: dict): - # import pdb; pdb.set_trace() for dev in data['devices']: # if device not exist in the Trade, then this query is wrong if not dev in data['action'].devices: - txt = "Device {} not exist in the revoke action".format(dev.devicehub_id) + txt = "Device {} not exist in the trade".format(dev.devicehub_id) raise ValidationError(txt) + for doc in data.get('documents', []): + # if document not exist in the Trade, then this query is wrong + if not doc in data['action'].documents: + txt = "Document {} not exist in the trade".format(doc.file_name) + raise ValidationError(txt) + + @validates_schema + def validate_docs(self, data): + """Check if there are or no one before confirmation, + This is not checked in the view becouse the list of documents is inmutable + + """ + if not data['devices'] == OrderedSet(): + return + + documents = [] + for doc in data['documents']: + actions = copy.copy(doc.actions) + actions.reverse() + for ac in actions: + if ac == data['action']: + # If document have the last action the action for confirm + documents.append(doc) + break + + if ac.t == 'Revoke' and not ac.user == g.user: + # If document is revoke before you can Confirm now + # and revoke is an action of one other user + documents.append(doc) + break + + if ac.t == ConfirmRevoke.t and ac.user == g.user: + # If document is confirmed we don't need confirmed again + break + + if ac.t == Confirm.t: + # if onwer of trade confirm again before than this user Confirm the + # revoke, then is not possible confirm the revoke + # + # If g.user confirm the trade before do a ConfirmRevoke + # then g.user can not to do the ConfirmRevoke more + break + + if not documents: + txt = 'No there are documents with revoke for confirm' + raise ValidationError(txt) + class Trade(ActionWithMultipleDevices): __doc__ = m.Trade.__doc__ - document_id = SanitizedStr(validate=Length(max=STR_SIZE), data_key='documentID', required=False) date = DateTime(data_key='date', required=False) price = Float(required=False, data_key='price') user_to_email = SanitizedStr( @@ -542,7 +717,13 @@ class Trade(ActionWithMultipleDevices): txt = "you need to be the owner of the lot for to do a trade" raise ValidationError(txt) + for doc in data['lot'].documents: + if not doc.owner == g.user: + txt = "you need to be the owner of the documents for to do a trade" + raise ValidationError(txt) + data['devices'] = data['lot'].devices + data['documents'] = data['lot'].documents @validates_schema def validate_user_to_email(self, data: dict): diff --git a/ereuse_devicehub/resources/action/views/trade.py b/ereuse_devicehub/resources/action/views/trade.py index 1f0b0ca3..ef7c4efe 100644 --- a/ereuse_devicehub/resources/action/views/trade.py +++ b/ereuse_devicehub/resources/action/views/trade.py @@ -1,11 +1,11 @@ -import copy - from flask import g from sqlalchemy.util import OrderedSet from teal.marshmallow import ValidationError from ereuse_devicehub.db import db -from ereuse_devicehub.resources.action.models import Trade, Confirm, ConfirmRevoke, Revoke +from ereuse_devicehub.resources.action.models import (Trade, Confirm, ConfirmRevoke, + Revoke, RevokeDocument, ConfirmDocument, + ConfirmRevokeDocument) from ereuse_devicehub.resources.user.models import User from ereuse_devicehub.resources.lot.views import delete_from_trade @@ -16,11 +16,11 @@ class TradeView(): request_post = { 'type': 'Trade', 'devices': [device_id], + 'documents': [document_id], 'userFrom': user2.email, 'userTo': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirm': True, } @@ -51,10 +51,17 @@ class TradeView(): # if the confirmation is mandatory, do automatic confirmation only for # owner of the lot if self.trade.confirm: - confirm = Confirm(user=g.user, - action=self.trade, - devices=self.trade.devices) - db.session.add(confirm) + if self.trade.devices: + confirm_devs = Confirm(user=g.user, + action=self.trade, + devices=self.trade.devices) + db.session.add(confirm_devs) + + if self.trade.documents: + confirm_docs = ConfirmDocument(user=g.user, + action=self.trade, + documents=self.trade.documents) + db.session.add(confirm_docs) return # check than the user than want to do the action is one of the users @@ -173,7 +180,6 @@ class ConfirmView(ConfirmMixin): """If there are one device than have one confirmation, then remove the list this device of the list of devices of this action """ - # import pdb; pdb.set_trace() real_devices = [] for dev in data['devices']: ac = dev.last_action_trading @@ -261,3 +267,111 @@ class ConfirmRevokeView(ConfirmMixin): dev.reset_owner() trade.lot.devices.difference_update(devices) + + +class ConfirmDocumentMixin(): + """ + Very Important: + ============== + All of this Views than inherit of this class is executed for users + than is not owner of the Trade action. + + The owner of Trade action executed this actions of confirm and revoke from the + lot + + """ + + Model = None + + def __init__(self, data, resource_def, schema): + # import pdb; pdb.set_trace() + self.schema = schema + a = resource_def.schema.load(data) + self.validate(a) + if not a['documents']: + raise ValidationError('Documents not exist.') + self.model = self.Model(**a) + + def post(self): + db.session().final_flush() + ret = self.schema.jsonify(self.model) + ret.status_code = 201 + db.session.commit() + return ret + + +class ConfirmDocumentView(ConfirmDocumentMixin): + """Handler for manager the Confirmation register from post + + request_confirm = { + 'type': 'Confirm', + 'action': trade.id, + 'documents': [document_id], + } + """ + + Model = ConfirmDocument + + def validate(self, data): + """If there are one device than have one confirmation, + then remove the list this device of the list of devices of this action + """ + for doc in data['documents']: + ac = doc.trading + if not doc.trading in ['Confirm', 'Need Confirmation']: + txt = 'Some of documents do not have enough to confirm for to do a Doble Confirmation' + ValidationError(txt) + ### End check ### + + +class RevokeDocumentView(ConfirmDocumentMixin): + """Handler for manager the Revoke register from post + + request_revoke = { + 'type': 'Revoke', + 'action': trade.id, + 'documents': [document_id], + } + + """ + + Model = RevokeDocument + + def validate(self, data): + """All devices need to have the status of DoubleConfirmation.""" + + ### check ### + if not data['documents']: + raise ValidationError('Documents not exist.') + + for doc in data['documents']: + if not doc.trading in ['Document Confirmed', 'Confirm']: + txt = 'Some of documents do not have enough to confirm for to do a revoke' + ValidationError(txt) + ### End check ### + + +class ConfirmRevokeDocumentView(ConfirmDocumentMixin): + """Handler for manager the Confirmation register from post + + request_confirm_revoke = { + 'type': 'ConfirmRevoke', + 'action': action_revoke.id, + 'documents': [document_id], + } + + """ + + Model = ConfirmRevokeDocument + + def validate(self, data): + """All devices need to have the status of revoke.""" + + if not data['action'].type == 'RevokeDocument': + txt = 'Error: this action is not a revoke action' + ValidationError(txt) + + for doc in data['documents']: + if not doc.trading == 'Revoke': + txt = 'Some of documents do not have revoke to confirm' + ValidationError(txt) diff --git a/ereuse_devicehub/resources/action/views/views.py b/ereuse_devicehub/resources/action/views/views.py index 8cb324ff..fbb15ea6 100644 --- a/ereuse_devicehub/resources/action/views/views.py +++ b/ereuse_devicehub/resources/action/views/views.py @@ -202,6 +202,19 @@ class ActionView(View): confirm_revoke = trade_view.ConfirmRevokeView(json, resource_def, self.schema) return confirm_revoke.post() + if json['type'] == 'RevokeDocument': + revoke = trade_view.RevokeDocumentView(json, resource_def, self.schema) + return revoke.post() + + if json['type'] == 'ConfirmDocument': + confirm = trade_view.ConfirmDocumentView(json, resource_def, self.schema) + return confirm.post() + + if json['type'] == 'ConfirmRevokeDocument': + confirm_revoke = trade_view.ConfirmRevokeDocumentView(json, resource_def, self.schema) + return confirm_revoke.post() + + # import pdb; pdb.set_trace() a = resource_def.schema.load(json) Model = db.Model._decl_class_registry.data[json['type']]() action = Model(**a) diff --git a/ereuse_devicehub/resources/device/models.py b/ereuse_devicehub/resources/device/models.py index 61363ed8..d586a102 100644 --- a/ereuse_devicehub/resources/device/models.py +++ b/ereuse_devicehub/resources/device/models.py @@ -625,11 +625,11 @@ class Computer(Device): It is a subset of the Linux definition of DMI / DMI decode. """ amount = Column(Integer, check_range('amount', min=0, max=100), default=0) - owner_id = db.Column(UUID(as_uuid=True), - db.ForeignKey(User.id), - nullable=False, - default=lambda: g.user.id) - author = db.relationship(User, primaryjoin=owner_id == User.id) + # owner_id = db.Column(UUID(as_uuid=True), + # db.ForeignKey(User.id), + # nullable=False, + # default=lambda: g.user.id) + # author = db.relationship(User, primaryjoin=owner_id == User.id) transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False) transfer_state.comment = TransferState.__doc__ receiver_id = db.Column(UUID(as_uuid=True), diff --git a/ereuse_devicehub/resources/device/schemas.py b/ereuse_devicehub/resources/device/schemas.py index 828c9827..d9a658fe 100644 --- a/ereuse_devicehub/resources/device/schemas.py +++ b/ereuse_devicehub/resources/device/schemas.py @@ -50,7 +50,7 @@ class Device(Thing): description='The lots where this device is directly under.') rate = NestedOn('Rate', dump_only=True, description=m.Device.rate.__doc__) price = NestedOn('Price', dump_only=True, description=m.Device.price.__doc__) - trading = EnumField(states.Trading, dump_only=True, description=m.Device.trading.__doc__) + # trading = EnumField(states.Trading, dump_only=True, description=m.Device.trading.__doc__) trading = SanitizedStr(dump_only=True, description='') physical = EnumField(states.Physical, dump_only=True, description=m.Device.physical.__doc__) traking= EnumField(states.Traking, dump_only=True, description=m.Device.physical.__doc__) diff --git a/ereuse_devicehub/resources/lot/schemas.py b/ereuse_devicehub/resources/lot/schemas.py index 2119c048..5604cc2e 100644 --- a/ereuse_devicehub/resources/lot/schemas.py +++ b/ereuse_devicehub/resources/lot/schemas.py @@ -5,6 +5,7 @@ from ereuse_devicehub.marshmallow import NestedOn 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.tradedocument import schemas as s_document from ereuse_devicehub.resources.enums import TransferState from ereuse_devicehub.resources.lot import models as m from ereuse_devicehub.resources.models import STR_SIZE @@ -27,4 +28,5 @@ class Lot(Thing): transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment) receiver_address = SanitizedStr(validate=f.validate.Length(max=42)) deliverynote = NestedOn(s_deliverynote.Deliverynote, dump_only=True) + documents = NestedOn('TradeDocument', many=True, dump_only=True) trade = NestedOn(s_action.Trade, dump_only=True) diff --git a/ereuse_devicehub/resources/tradedocument/__init__.py b/ereuse_devicehub/resources/tradedocument/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ereuse_devicehub/resources/tradedocument/definitions.py b/ereuse_devicehub/resources/tradedocument/definitions.py new file mode 100644 index 00000000..e321c7b4 --- /dev/null +++ b/ereuse_devicehub/resources/tradedocument/definitions.py @@ -0,0 +1,10 @@ +from teal.resource import Converters, Resource + +from ereuse_devicehub.resources.tradedocument import schemas +from ereuse_devicehub.resources.tradedocument.views import TradeDocumentView + +class TradeDocumentDef(Resource): + SCHEMA = schemas.TradeDocument + VIEW = TradeDocumentView + AUTH = True + ID_CONVERTER = Converters.string diff --git a/ereuse_devicehub/resources/tradedocument/models.py b/ereuse_devicehub/resources/tradedocument/models.py new file mode 100644 index 00000000..9c743244 --- /dev/null +++ b/ereuse_devicehub/resources/tradedocument/models.py @@ -0,0 +1,141 @@ +import copy +from citext import CIText +from flask import g + +from sqlalchemy.dialects.postgresql import UUID +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.user.models import User +from sortedcontainers import SortedSet +from ereuse_devicehub.resources.models import Thing + +from sqlalchemy import BigInteger, Column, Sequence +from sqlalchemy.orm import backref +from teal.db import CASCADE_OWN, URL + +from ereuse_devicehub.resources.enums import Severity + + +_sorted_documents = { + 'order_by': lambda: TradeDocument.created, + 'collection_class': SortedSet +} + +class TradeDocument(Thing): + """This represent a document involved in a trade action. + Every document is added to a lot. + When this lot is converted in one trade, the action trade is added to the document + and the action trade need to be confirmed for the both users of the trade. + This confirmation can be revoked and this revoked need to be ConfirmRevoke for have + some efect. + + This documents can be invoices or list of devices or certificates of erasure of + one disk. + + Like a Devices one document have actions and is possible add or delete of one lot + if this lot don't have a trade + + The document is saved in the database + + """ + + id = Column(BigInteger, Sequence('device_seq'), primary_key=True) + id.comment = """The identifier of the device for this database. Used only + internally for software; users should not use this. + """ + # type = Column(Unicode(STR_SM_SIZE), nullable=False) + date = Column(db.DateTime) + date.comment = """The date of document, some documents need to have one date + """ + id_document = Column(CIText()) + id_document.comment = """The id of one document like invoice so they can be linked.""" + description = Column(db.CIText()) + description.comment = """A description of document.""" + 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) + lot_id = db.Column(UUID(as_uuid=True), + db.ForeignKey('lot.id'), + nullable=False) + lot = db.relationship('Lot', + backref=backref('documents', + lazy=True, + cascade=CASCADE_OWN, + **_sorted_documents), + primaryjoin='TradeDocument.lot_id == Lot.id') + lot.comment = """Lot to which the document is associated""" + file_name = Column(db.CIText()) + file_name.comment = """This is the name of the file when user up the document.""" + file_hash = Column(db.CIText()) + file_hash.comment = """This is the hash of the file produced from frontend.""" + url = db.Column(URL()) + url.comment = """This is the url where resides the document.""" + + __table_args__ = ( + db.Index('document_id', id, postgresql_using='hash'), + # db.Index('type_doc', type, postgresql_using='hash') + ) + + @property + def actions(self) -> list: + """All the actions where the device participated, including: + + 1. Actions performed directly to the device. + 2. Actions performed to a component. + 3. Actions performed to a parent device. + + Actions are returned by descending ``created`` time. + """ + return sorted(self.actions_docs, key=lambda x: x.created) + + @property + def trading(self): + """The trading state, or None if no Trade action has + ever been performed to this device. This extract the posibilities for to do""" + + confirm = 'Confirm' + need_confirm = 'Need Confirmation' + double_confirm = 'Document Confirmed' + revoke = 'Revoke' + revoke_pending = 'Revoke Pending' + confirm_revoke = 'Document Revoked' + + if not self.actions: + return + + ac = self.actions[-1] + + if ac.type == 'ConfirmRevokeDocument': + # can to do revoke_confirmed + return confirm_revoke + + if ac.type == 'RevokeDocument': + if ac.user == g.user: + # can todo revoke_pending + return revoke_pending + else: + # can to do confirm_revoke + return revoke + + if ac.type == 'ConfirmDocument': + if ac.user == self.owner: + if self.owner == g.user: + # can to do revoke + return confirm + else: + # can to do confirm + return need_confirm + else: + # can to do revoke + return double_confirm + + def _warning_actions(self, actions): + return sorted(ev for ev in actions if ev.severity >= Severity.Warning) + + + def __lt__(self, other): + return self.id < other.id + + def __str__(self) -> str: + return '{0.file_name}'.format(self) diff --git a/ereuse_devicehub/resources/tradedocument/schemas.py b/ereuse_devicehub/resources/tradedocument/schemas.py new file mode 100644 index 00000000..fab95dc4 --- /dev/null +++ b/ereuse_devicehub/resources/tradedocument/schemas.py @@ -0,0 +1,31 @@ +from marshmallow.fields import DateTime, Integer, validate +from teal.marshmallow import SanitizedStr, URL +# from marshmallow import ValidationError, validates_schema + +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.resources.lot import schemas as s_lot + + +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)) + url = URL(description=m.TradeDocument.url.comment) + lot = NestedOn('Lot', only_query='id', description=m.TradeDocument.lot.__doc__) + trading = SanitizedStr(dump_only=True, description='') diff --git a/ereuse_devicehub/resources/tradedocument/views.py b/ereuse_devicehub/resources/tradedocument/views.py new file mode 100644 index 00000000..4222eceb --- /dev/null +++ b/ereuse_devicehub/resources/tradedocument/views.py @@ -0,0 +1,47 @@ +import os +import time +from datetime import datetime +from flask import current_app as app, request, g, Response +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 + + +class TradeDocumentView(View): + + def one(self, id: str): + doc = TradeDocument.query.filter_by(id=id, owner=g.user).one() + return self.schema.jsonify(doc) + + def post(self): + """Add one document.""" + + data = request.get_json(validate=True) + hash3 = data['file_hash'] + db_hash = ReportHash(hash3=hash3) + db.session.add(db_hash) + + doc = TradeDocument(**data) + trade = doc.lot.trade + if trade: + trade.documents.add(doc) + confirm = ConfirmDocument(action=trade, + user=g.user, + devices=set(), + documents={doc}) + db.session.add(confirm) + db.session.add(doc) + db.session().final_flush() + ret = self.schema.jsonify(doc) + ret.status_code = 201 + db.session.commit() + return ret + + def delete(self, id): + doc = TradeDocument.query.filter_by(id=id, owner=g.user).one() + db.session.delete(doc) + db.session.commit() + return Response(status=204) diff --git a/ereuse_devicehub/resources/user/models.py b/ereuse_devicehub/resources/user/models.py index f1690156..79525286 100644 --- a/ereuse_devicehub/resources/user/models.py +++ b/ereuse_devicehub/resources/user/models.py @@ -59,6 +59,13 @@ class User(Thing): """The individual associated for this database, or None.""" return next(iter(self.individuals), None) + @property + def code(self): + """Code of phantoms accounts""" + if not self.phantom: + return + return self.email.split('@')[0].split('_')[1] + class UserInventory(db.Model): """Relationship between users and their inventories.""" diff --git a/ereuse_devicehub/resources/user/schemas.py b/ereuse_devicehub/resources/user/schemas.py index 220e193b..a70c0da8 100644 --- a/ereuse_devicehub/resources/user/schemas.py +++ b/ereuse_devicehub/resources/user/schemas.py @@ -23,6 +23,7 @@ class User(Thing): 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, diff --git a/tests/conftest.py b/tests/conftest.py index 4c45dc06..9dd556eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,7 @@ class TestConfig(DevicehubConfig): TMP_SNAPSHOTS = '/tmp/snapshots' TMP_LIVES = '/tmp/lives' EMAIL_ADMIN = 'foo@foo.com' + PATH_DOCUMENTS_STORAGE = '/tmp/trade_documents' @pytest.fixture(scope='session') diff --git a/tests/test_action.py b/tests/test_action.py index 6e1aca2b..4b5640cb 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -765,7 +765,6 @@ def test_trade_endpoint(user: UserClient, user2: UserClient): device2, _ = user2.get(res=Device, item=device['id']) assert device2['id'] == device['id'] - @pytest.mark.mvp @pytest.mark.usefixtures(conftest.app_context.__name__) def test_offer_without_to(user: UserClient): @@ -789,7 +788,6 @@ def test_offer_without_to(user: UserClient): 'userFromEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': False, 'code': 'MAX' @@ -817,7 +815,6 @@ def test_offer_without_to(user: UserClient): 'userFromEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': False, 'code': 'MAX' @@ -840,7 +837,6 @@ def test_offer_without_to(user: UserClient): 'userFromEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot2.id, 'confirms': False, 'code': 'MAX' @@ -871,7 +867,6 @@ def test_offer_without_from(user: UserClient, user2: UserClient): 'userToEmail': user2.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot.id, 'confirms': False, 'code': 'MAX' @@ -916,7 +911,6 @@ def test_offer_without_users(user: UserClient): 'devices': [device.id], 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot.id, 'confirms': False, 'code': 'MAX' @@ -950,7 +944,6 @@ def test_offer(user: UserClient): 'userToEmail': user2.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot.id, 'confirms': True, } @@ -977,7 +970,6 @@ def test_offer_without_devices(user: UserClient): 'userToEmail': user2.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1036,7 +1028,6 @@ def test_erase_physical(): db.session.add(erasure) db.session.commit() - @pytest.mark.mvp @pytest.mark.usefixtures(conftest.app_context.__name__) def test_endpoint_confirm(user: UserClient, user2: UserClient): @@ -1056,7 +1047,6 @@ def test_endpoint_confirm(user: UserClient, user2: UserClient): 'userToEmail': user2.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1097,7 +1087,6 @@ def test_confirm_revoke(user: UserClient, user2: UserClient): 'userToEmail': user2.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1172,7 +1161,6 @@ def test_usecase_confirmation(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1362,7 +1350,6 @@ def test_confirmRevoke(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1477,7 +1464,6 @@ def test_trade_case1(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1538,7 +1524,6 @@ def test_trade_case2(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1603,7 +1588,6 @@ def test_trade_case3(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1661,7 +1645,6 @@ def test_trade_case4(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1727,7 +1710,6 @@ def test_trade_case5(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1793,7 +1775,6 @@ def test_trade_case6(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1861,7 +1842,6 @@ def test_trade_case7(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -1927,7 +1907,6 @@ def test_trade_case8(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -2000,7 +1979,6 @@ def test_trade_case9(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -2081,7 +2059,6 @@ def test_trade_case10(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -2166,7 +2143,6 @@ def test_trade_case11(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -2235,7 +2211,6 @@ def test_trade_case12(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -2310,7 +2285,6 @@ def test_trade_case13(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } @@ -2385,7 +2359,6 @@ def test_trade_case14(user: UserClient, user2: UserClient): 'userToEmail': user.email, 'price': 10, 'date': "2020-12-01T02:00:00+00:00", - 'documentID': '1', 'lot': lot['id'], 'confirms': True, } diff --git a/tests/test_basic.py b/tests/test_basic.py index bb53fda0..4cdce061 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -55,6 +55,7 @@ def test_api_docs(client: Client): '/metrics/', '/tags/', '/tags/{tag_id}/device/{device_id}', + '/trade-documents/', '/users/', '/users/login/', '/users/logout/', @@ -121,4 +122,4 @@ def test_api_docs(client: Client): 'scheme': 'basic', 'name': 'Authorization' } - assert len(docs['definitions']) == 121 + assert len(docs['definitions']) == 125