diff --git a/ereuse_devicehub/inventory/forms.py b/ereuse_devicehub/inventory/forms.py index 1226dcbf..d2107d92 100644 --- a/ereuse_devicehub/inventory/forms.py +++ b/ereuse_devicehub/inventory/forms.py @@ -26,6 +26,7 @@ from wtforms import ( from wtforms.fields import FormField from ereuse_devicehub.db import db +from ereuse_devicehub.inventory.models import Transfer from ereuse_devicehub.resources.action.models import RateComputer, Snapshot, Trade from ereuse_devicehub.resources.action.rate.v1_0 import CannotRate from ereuse_devicehub.resources.action.schemas import Snapshot as SnapshotSchema @@ -1098,3 +1099,85 @@ class TradeDocumentForm(FlaskForm): db.session.commit() return self._obj + + +class TransferForm(FlaskForm): + code = StringField( + 'Code', + [validators.DataRequired()], + render_kw={'class': "form-control"}, + description="You need put a code for transfer the external user", + ) + description = TextAreaField( + 'Description', + [validators.Optional()], + render_kw={'class': "form-control"}, + ) + type = HiddenField() + + def __init__(self, *args, **kwargs): + self._type = kwargs.get('type') + lot_id = kwargs.pop('lot_id', None) + self._tmp_lot = Lot.query.filter(Lot.id == lot_id).one() + super().__init__(*args, **kwargs) + self._obj = None + + def validate(self, extra_validators=None): + is_valid = super().validate(extra_validators) + if not self._tmp_lot: + return False + + if self._type and self.type.data not in ['incoming', 'outgoing']: + return False + + if self._obj and self.date.data: + if self.date.data > datetime.datetime.now().date(): + return False + + return is_valid + + def save(self, commit=True): + self.set_obj() + db.session.add(self._obj) + + if commit: + db.session.commit() + + return self._obj + + def set_obj(self): + self.newlot = Lot(name=self._tmp_lot.name) + self.newlot.devices = self._tmp_lot.devices + db.session.add(self.newlot) + + self._obj = Transfer(lot=self.newlot) + + self.populate_obj(self._obj) + + if self.type.data == 'incoming': + self._obj.user_to = g.user + elif self.type.data == 'outgoing': + self._obj.user_from = g.user + + +class EditTransferForm(TransferForm): + date = DateField( + 'Date', + [validators.Optional()], + render_kw={'class': "form-control"}, + description="""Date when the transfer is closed""", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + del self.type + + self._obj = self._tmp_lot.transfer + + if not self.data['csrf_token']: + self.code.data = self._obj.code + self.description.data = self._obj.description + self.date.data = self._obj.date + + def set_obj(self, commit=True): + self.populate_obj(self._obj) diff --git a/ereuse_devicehub/inventory/models.py b/ereuse_devicehub/inventory/models.py new file mode 100644 index 00000000..f8aafe51 --- /dev/null +++ b/ereuse_devicehub/inventory/models.py @@ -0,0 +1,44 @@ +from uuid import uuid4 + +from citext import CIText +from sqlalchemy import Column +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import backref, relationship +from teal.db import CASCADE_OWN + +from ereuse_devicehub.db import db +from ereuse_devicehub.resources.models import Thing +from ereuse_devicehub.resources.user.models import User + + +class Transfer(Thing): + """ + The transfer is a transfer of possession of devices between + a user and a code (not system user) + """ + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) + code = Column(CIText(), default='', nullable=False) + date = Column(db.TIMESTAMP(timezone=True)) + description = Column(CIText(), default='', nullable=True) + lot_id = db.Column( + UUID(as_uuid=True), + db.ForeignKey('lot.id', use_alter=True, name='lot_trade'), + nullable=True, + ) + lot = relationship( + 'Lot', + backref=backref('transfer', lazy=True, uselist=False, cascade=CASCADE_OWN), + primaryjoin='Transfer.lot_id == Lot.id', + ) + user_from_id = db.Column(UUID(as_uuid=True), db.ForeignKey(User.id), nullable=True) + user_from = db.relationship(User, primaryjoin=user_from_id == User.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) + + @property + def closed(self): + if self.date: + return True + + return False diff --git a/ereuse_devicehub/inventory/views.py b/ereuse_devicehub/inventory/views.py index 5ed4247a..dfde9a47 100644 --- a/ereuse_devicehub/inventory/views.py +++ b/ereuse_devicehub/inventory/views.py @@ -15,6 +15,7 @@ from ereuse_devicehub.db import db from ereuse_devicehub.inventory.forms import ( AllocateForm, DataWipeForm, + EditTransferForm, FilterForm, LotForm, NewActionForm, @@ -22,6 +23,7 @@ from ereuse_devicehub.inventory.forms import ( TagDeviceForm, TradeDocumentForm, TradeForm, + TransferForm, UploadSnapshotForm, ) from ereuse_devicehub.labels.forms import PrintLabelsForm @@ -47,16 +49,12 @@ class DeviceListMix(GenericMixView): form_filter = FilterForm(lots, lot_id, only_unassigned=only_unassigned) devices = form_filter.search() lot = None + form_transfer = '' if lot_id: lot = lots.filter(Lot.id == lot_id).one() - form_new_trade = TradeForm( - lot=lot.id, - user_to=g.user.email, - user_from=g.user.email, - ) - else: - form_new_trade = '' + if not lot.is_temporary and lot.transfer: + form_transfer = EditTransferForm(lot_id=lot.id) form_new_action = NewActionForm(lot=lot_id) self.context.update( @@ -66,7 +64,7 @@ class DeviceListMix(GenericMixView): 'form_new_action': form_new_action, 'form_new_allocate': AllocateForm(lot=lot_id), 'form_new_datawipe': DataWipeForm(lot=lot_id), - 'form_new_trade': form_new_trade, + 'form_transfer': form_transfer, 'form_filter': form_filter, 'form_print_labels': PrintLabelsForm(), 'lot': lot, @@ -400,6 +398,48 @@ class NewTradeDocumentView(View): return flask.render_template(self.template_name, **self.context) +class NewTransferView(GenericMixView): + methods = ['POST', 'GET'] + template_name = 'inventory/new_transfer.html' + form_class = TransferForm + title = "Add new transfer" + + def dispatch_request(self, lot_id, type_id): + self.form = self.form_class(lot_id=lot_id, type=type_id) + self.get_context() + + if self.form.validate_on_submit(): + self.form.save() + new_lot_id = lot_id + if self.form.newlot.id: + new_lot_id = "{}".format(self.form.newlot.id) + Lot.query.filter(Lot.id == new_lot_id).one() + messages.success('Transfer created successfully!') + next_url = url_for('inventory.lotdevicelist', lot_id=str(new_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 EditTransferView(GenericMixView): + methods = ['POST'] + form_class = EditTransferForm + + def dispatch_request(self, lot_id): + self.get_context() + form = self.form_class(request.form, lot_id=lot_id) + next_url = url_for('inventory.lotdevicelist', lot_id=lot_id) + + if form.validate_on_submit(): + form.save() + messages.success('Transfer updated successfully!') + return flask.redirect(next_url) + + messages.error('Transfer updated error!') + return flask.redirect(next_url) + + class ExportsView(View): methods = ['GET'] decorators = [login_required] @@ -557,3 +597,11 @@ devices.add_url_rule( devices.add_url_rule( '/export//', view_func=ExportsView.as_view('export') ) +devices.add_url_rule( + '/lot//transfer//', + view_func=NewTransferView.as_view('new_transfer'), +) +devices.add_url_rule( + '/lot//transfer/', + view_func=EditTransferView.as_view('edit_transfer'), +) diff --git a/ereuse_devicehub/migrations/versions/054a3aea9f08_transfer.py b/ereuse_devicehub/migrations/versions/054a3aea9f08_transfer.py new file mode 100644 index 00000000..4a09b774 --- /dev/null +++ b/ereuse_devicehub/migrations/versions/054a3aea9f08_transfer.py @@ -0,0 +1,124 @@ +"""transfer + +Revision ID: 054a3aea9f08 +Revises: 8571fb32c912 +Create Date: 2022-05-27 11:07:18.245322 + +""" +from uuid import uuid4 + +import citext +import sqlalchemy as sa +from alembic import context, op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '054a3aea9f08' +down_revision = '8571fb32c912' +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_datas(): + sql = f'select user_from_id, user_to_id, lot_id, code from {get_inv()}.trade where confirm=False' + con = op.get_bind() + + sql_phantom = 'select id from common.user where phantom=True' + phantoms = [x[0] for x in con.execute(sql_phantom)] + + for ac in con.execute(sql): + id = uuid4() + user_from = ac.user_from_id + user_to = ac.user_to_id + lot = ac.lot_id + code = ac.code + columns = '(id, user_from_id, user_to_id, lot_id, code)' + values = f'(\'{id}\', \'{user_from}\', \'{user_to}\', \'{lot}\', \'{code}\')' + if user_to not in phantoms: + columns = '(id, user_to_id, lot_id, code)' + values = f'(\'{id}\', \'{user_to}\', \'{lot}\', \'{code}\')' + if user_from not in phantoms: + columns = '(id, user_from_id, lot_id, code)' + values = f'(\'{id}\', \'{user_from}\', \'{lot}\', \'{code}\')' + new_transfer = f'insert into {get_inv()}.transfer {columns} values {values}' + op.execute(new_transfer) + + +def upgrade(): + # creating transfer table + op.create_table( + 'transfer', + 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('code', citext.CIText(), nullable=False), + sa.Column( + 'description', + citext.CIText(), + nullable=True, + comment='A comment about the action.', + ), + sa.Column('date', sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('user_to_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('user_from_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint(['lot_id'], [f'{get_inv()}.lot.id']), + sa.ForeignKeyConstraint(['user_from_id'], ['common.user.id']), + sa.ForeignKeyConstraint(['user_to_id'], ['common.user.id']), + sa.PrimaryKeyConstraint('id'), + schema=f'{get_inv()}', + ) + + # creating index + op.create_index( + op.f('ix_transfer_created'), + 'transfer', + ['created'], + unique=False, + schema=f'{get_inv()}', + ) + op.create_index( + op.f('ix_transfer_updated'), + 'transfer', + ['updated'], + unique=False, + schema=f'{get_inv()}', + ) + op.create_index( + 'ix_transfer_id', + 'transfer', + ['id'], + unique=False, + postgresql_using='hash', + schema=f'{get_inv()}', + ) + + upgrade_datas() + + +def downgrade(): + op.drop_index( + op.f('ix_transfer_created'), table_name='transfer', schema=f'{get_inv()}' + ) + op.drop_index( + op.f('ix_transfer_updated'), table_name='transfer', schema=f'{get_inv()}' + ) + op.drop_index(op.f('ix_transfer_id'), table_name='transfer', schema=f'{get_inv()}') + op.drop_table('transfer', schema=f'{get_inv()}') diff --git a/ereuse_devicehub/resources/action/views/trade.py b/ereuse_devicehub/resources/action/views/trade.py index 87a5dc81..4e2a31d5 100644 --- a/ereuse_devicehub/resources/action/views/trade.py +++ b/ereuse_devicehub/resources/action/views/trade.py @@ -1,29 +1,34 @@ 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, - Revoke, RevokeDocument, ConfirmDocument, - ConfirmRevokeDocument) -from ereuse_devicehub.resources.user.models import User +from ereuse_devicehub.inventory.models import Transfer +from ereuse_devicehub.resources.action.models import ( + Confirm, + ConfirmDocument, + ConfirmRevokeDocument, + Revoke, + RevokeDocument, + Trade, +) from ereuse_devicehub.resources.lot.views import delete_from_trade +from ereuse_devicehub.resources.user.models import User -class TradeView(): +class TradeView: """Handler for manager the trade action register from post - 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", - 'lot': lot['id'], - 'confirm': True, - } + 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", + 'lot': lot['id'], + 'confirm': True, + } """ @@ -37,6 +42,7 @@ class TradeView(): db.session.add(self.trade) self.create_confirmations() self.create_automatic_trade() + self.create_transfer() def post(self): db.session().final_flush() @@ -52,15 +58,15 @@ class TradeView(): # owner of the lot if self.trade.confirm: if self.trade.devices: - confirm_devs = Confirm(user=g.user, - action=self.trade, - devices=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) + confirm_docs = ConfirmDocument( + user=g.user, action=self.trade, documents=self.trade.documents + ) db.session.add(confirm_docs) return @@ -70,12 +76,12 @@ class TradeView(): txt = "You do not participate in this trading" raise ValidationError(txt) - confirm_from = Confirm(user=self.trade.user_from, - action=self.trade, - devices=self.trade.devices) - confirm_to = Confirm(user=self.trade.user_to, - action=self.trade, - devices=self.trade.devices) + confirm_from = Confirm( + user=self.trade.user_from, action=self.trade, devices=self.trade.devices + ) + confirm_to = Confirm( + user=self.trade.user_to, action=self.trade, devices=self.trade.devices + ) db.session.add(confirm_from) db.session.add(confirm_to) @@ -124,6 +130,25 @@ class TradeView(): db.session.add(user) self.data['user_from'] = user + def create_transfer(self): + code = self.trade.code + confirm = self.trade.confirm + lot = self.trade.lot + user_from = None + user_to = None + + if not self.trade.user_from.phantom: + user_from = self.trade.user_from + if not self.trade.user_to.phantom: + user_to = self.trade.user_to + if (user_from and user_to) or not code or confirm: + return + + self.transfer = Transfer( + code=code, user_from=user_from, user_to=user_to, lot=lot + ) + db.session.add(self.transfer) + def create_automatic_trade(self) -> None: # not do nothing if it's neccesary confirmation explicity if self.trade.confirm: @@ -134,15 +159,15 @@ class TradeView(): dev.change_owner(self.trade.user_to) -class ConfirmMixin(): +class ConfirmMixin: """ - Very Important: - ============== - All of this Views than inherit of this class is executed for users - than is not owner of the Trade action. + 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 + The owner of Trade action executed this actions of confirm and revoke from the + lot """ @@ -167,24 +192,27 @@ class ConfirmMixin(): class ConfirmView(ConfirmMixin): """Handler for manager the Confirmation register from post - request_confirm = { - 'type': 'Confirm', - 'action': trade.id, - 'devices': [device_id] - } + request_confirm = { + 'type': 'Confirm', + 'action': trade.id, + 'devices': [device_id] + } """ Model = Confirm 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 + then remove the list this device of the list of devices of this action """ real_devices = [] trade = data['action'] lot = trade.lot for dev in data['devices']: - if dev.trading(lot, simple=True) not in ['NeedConfirmation', 'NeedConfirmRevoke']: + if dev.trading(lot, simple=True) not in [ + 'NeedConfirmation', + 'NeedConfirmRevoke', + ]: raise ValidationError('Some devices not possible confirm.') # Change the owner for every devices @@ -197,11 +225,11 @@ class ConfirmView(ConfirmMixin): class RevokeView(ConfirmMixin): """Handler for manager the Revoke register from post - request_revoke = { - 'type': 'Revoke', - 'action': trade.id, - 'devices': [device_id], - } + request_revoke = { + 'type': 'Revoke', + 'action': trade.id, + 'devices': [device_id], + } """ @@ -223,15 +251,15 @@ class RevokeView(ConfirmMixin): self.model = delete_from_trade(lot, devices) -class ConfirmDocumentMixin(): +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. + 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 + The owner of Trade action executed this actions of confirm and revoke from the + lot """ @@ -256,18 +284,18 @@ class ConfirmDocumentMixin(): class ConfirmDocumentView(ConfirmDocumentMixin): """Handler for manager the Confirmation register from post - request_confirm = { - 'type': 'Confirm', - 'action': trade.id, - 'documents': [document_id], - } + 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 + then remove the list this device of the list of devices of this action """ for doc in data['documents']: ac = doc.trading @@ -280,11 +308,11 @@ class ConfirmDocumentView(ConfirmDocumentMixin): class RevokeDocumentView(ConfirmDocumentMixin): """Handler for manager the Revoke register from post - request_revoke = { - 'type': 'Revoke', - 'action': trade.id, - 'documents': [document_id], - } + request_revoke = { + 'type': 'Revoke', + 'action': trade.id, + 'documents': [document_id], + } """ @@ -299,7 +327,9 @@ class RevokeDocumentView(ConfirmDocumentMixin): 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' + txt = ( + 'Some of documents do not have enough to confirm for to do a revoke' + ) ValidationError(txt) ### End check ### @@ -307,11 +337,11 @@ class RevokeDocumentView(ConfirmDocumentMixin): class ConfirmRevokeDocumentView(ConfirmDocumentMixin): """Handler for manager the Confirmation register from post - request_confirm_revoke = { - 'type': 'ConfirmRevoke', - 'action': action_revoke.id, - 'documents': [document_id], - } + request_confirm_revoke = { + 'type': 'ConfirmRevoke', + 'action': action_revoke.id, + 'documents': [document_id], + } """ diff --git a/ereuse_devicehub/resources/lot/models.py b/ereuse_devicehub/resources/lot/models.py index 88c68626..ef6577a8 100644 --- a/ereuse_devicehub/resources/lot/models.py +++ b/ereuse_devicehub/resources/lot/models.py @@ -5,12 +5,11 @@ from typing import Union from boltons import urlutils from citext import CIText from flask import g -from flask_login import current_user from sqlalchemy import TEXT from sqlalchemy.dialects.postgresql import UUID from sqlalchemy_utils import LtreeType from sqlalchemy_utils.types.ltree import LQUERY -from teal.db import CASCADE_OWN, UUIDLtree, check_range, IntEnum +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 @@ -21,70 +20,88 @@ from ereuse_devicehub.resources.user.models import User class Lot(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 name = db.Column(CIText(), nullable=False) description = db.Column(CIText()) description.comment = """A comment about the lot.""" 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('lots', lazy=True, collection_class=set), - secondary=lambda: LotDevice.__table__, - lazy=True, - collection_class=set) + devices = db.relationship( + Device, + backref=db.backref('lots', lazy=True, collection_class=set), + secondary=lambda: LotDevice.__table__, + lazy=True, + collection_class=set, + ) """The **children** devices that the lot has. Note that the lot can have more devices, if they are inside descendant lots. """ - parents = db.relationship(lambda: Lot, - viewonly=True, - lazy=True, - collection_class=set, - secondary=lambda: LotParent.__table__, - primaryjoin=lambda: Lot.id == LotParent.child_id, - secondaryjoin=lambda: LotParent.parent_id == Lot.id, - cascade='refresh-expire', # propagate changes outside ORM - backref=db.backref('children', - viewonly=True, - lazy=True, - cascade='refresh-expire', - collection_class=set) - ) + parents = db.relationship( + lambda: Lot, + viewonly=True, + lazy=True, + collection_class=set, + secondary=lambda: LotParent.__table__, + primaryjoin=lambda: Lot.id == LotParent.child_id, + secondaryjoin=lambda: LotParent.parent_id == Lot.id, + cascade='refresh-expire', # propagate changes outside ORM + backref=db.backref( + 'children', + viewonly=True, + lazy=True, + cascade='refresh-expire', + collection_class=set, + ), + ) """The parent lots.""" - all_devices = db.relationship(Device, - viewonly=True, - lazy=True, - collection_class=set, - secondary=lambda: LotDeviceDescendants.__table__, - primaryjoin=lambda: Lot.id == LotDeviceDescendants.ancestor_lot_id, - secondaryjoin=lambda: LotDeviceDescendants.device_id == Device.id) + all_devices = db.relationship( + Device, + viewonly=True, + lazy=True, + collection_class=set, + secondary=lambda: LotDeviceDescendants.__table__, + primaryjoin=lambda: Lot.id == LotDeviceDescendants.ancestor_lot_id, + secondaryjoin=lambda: LotDeviceDescendants.device_id == Device.id, + ) """All devices, including components, inside this lot and its descendants. """ amount = db.Column(db.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) + 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) - 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__ - receiver_address = db.Column(CIText(), - db.ForeignKey(User.email), - nullable=False, - default=lambda: g.user.email) + receiver_address = db.Column( + CIText(), + db.ForeignKey(User.email), + nullable=False, + default=lambda: g.user.email, + ) receiver = db.relationship(User, primaryjoin=receiver_address == User.email) - def __init__(self, name: str, closed: bool = closed.default.arg, - description: str = None) -> None: + def __init__( + self, name: str, closed: bool = closed.default.arg, description: str = None + ) -> None: """Initializes a lot :param name: :param closed: """ - super().__init__(id=uuid.uuid4(), name=name, closed=closed, description=description) + super().__init__( + id=uuid.uuid4(), name=name, closed=closed, description=description + ) Path(self) # Lots have always one edge per default. @property @@ -102,20 +119,32 @@ class Lot(Thing): @property def is_temporary(self): - return not bool(self.trade) + return not bool(self.trade) and not bool(self.transfer) @property def is_incoming(self): - return bool(self.trade and self.trade.user_to == current_user) + if self.trade: + return self.trade.user_to == g.user + if self.transfer: + return self.transfer.user_to == g.user + + return False @property def is_outgoing(self): - return bool(self.trade and self.trade.user_from == current_user) + if self.trade: + return self.trade.user_from == g.user + if self.transfer: + return self.transfer.user_from == g.user + + return False @classmethod def descendantsq(cls, id): _id = UUIDLtree.convert(id) - return (cls.id == Path.lot_id) & Path.path.lquery(exp.cast('*.{}.*'.format(_id), LQUERY)) + return (cls.id == Path.lot_id) & Path.path.lquery( + exp.cast('*.{}.*'.format(_id), LQUERY) + ) @classmethod def roots(cls): @@ -176,13 +205,17 @@ class Lot(Thing): if isinstance(child, Lot): return Path.has_lot(self.id, child.id) elif isinstance(child, Device): - device = db.session.query(LotDeviceDescendants) \ - .filter(LotDeviceDescendants.device_id == child.id) \ - .filter(LotDeviceDescendants.ancestor_lot_id == self.id) \ + device = ( + db.session.query(LotDeviceDescendants) + .filter(LotDeviceDescendants.device_id == child.id) + .filter(LotDeviceDescendants.ancestor_lot_id == self.id) .one_or_none() + ) return device else: - raise TypeError('Lot only contains devices and lots, not {}'.format(child.__class__)) + raise TypeError( + 'Lot only contains devices and lots, not {}'.format(child.__class__) + ) def __repr__(self) -> str: return ''.format(self) @@ -192,35 +225,44 @@ 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_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 Path(db.Model): - id = db.Column(db.UUID(as_uuid=True), - primary_key=True, - server_default=db.text('gen_random_uuid()')) + 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('paths', - lazy=True, - collection_class=set, - cascade=CASCADE_OWN), - primaryjoin=Lot.id == lot_id) + lot = db.relationship( + Lot, + backref=db.backref( + 'paths', lazy=True, collection_class=set, cascade=CASCADE_OWN + ), + primaryjoin=Lot.id == lot_id, + ) path = db.Column(LtreeType, nullable=False) - created = db.Column(db.TIMESTAMP(timezone=True), server_default=db.text('CURRENT_TIMESTAMP')) + created = db.Column( + db.TIMESTAMP(timezone=True), server_default=db.text('CURRENT_TIMESTAMP') + ) created.comment = """When Devicehub created this.""" __table_args__ = ( # dag.delete_edge needs to disable internally/temporarily the unique constraint - db.UniqueConstraint(path, name='path_unique', deferrable=True, initially='immediate'), + db.UniqueConstraint( + path, name='path_unique', deferrable=True, initially='immediate' + ), db.Index('path_gist', path, postgresql_using='gist'), db.Index('path_btree', path, postgresql_using='btree'), - db.Index('lot_id_index', lot_id, postgresql_using='hash') + db.Index('lot_id_index', lot_id, postgresql_using='hash'), ) def __init__(self, lot: Lot) -> None: @@ -243,7 +285,9 @@ class Path(db.Model): child_id = UUIDLtree.convert(child_id) return bool( db.session.execute( - "SELECT 1 from path where path ~ '*.{}.*.{}.*'".format(parent_id, child_id) + "SELECT 1 from path where path ~ '*.{}.*.{}.*'".format( + parent_id, child_id + ) ).first() ) @@ -263,47 +307,73 @@ class LotDeviceDescendants(db.Model): """Ancestor lot table.""" _desc = Lot.__table__.alias() """Descendant lot table.""" - lot_device = _desc \ - .join(LotDevice, _desc.c.id == LotDevice.lot_id) \ - .join(Path, _desc.c.id == Path.lot_id) + lot_device = _desc.join(LotDevice, _desc.c.id == LotDevice.lot_id).join( + Path, _desc.c.id == Path.lot_id + ) """Join: Path -- Lot -- LotDevice""" - descendants = "path.path ~ (CAST('*.'|| replace(CAST({}.id as text), '-', '_') " \ - "|| '.*' AS LQUERY))".format(_ancestor.name) + descendants = ( + "path.path ~ (CAST('*.'|| replace(CAST({}.id as text), '-', '_') " + "|| '.*' AS LQUERY))".format(_ancestor.name) + ) """Query that gets the descendants of the ancestor lot.""" - devices = db.select([ - LotDevice.device_id, - _desc.c.id.label('parent_lot_id'), - _ancestor.c.id.label('ancestor_lot_id'), - None - ]).select_from(_ancestor).select_from(lot_device).where(db.text(descendants)) + devices = ( + db.select( + [ + LotDevice.device_id, + _desc.c.id.label('parent_lot_id'), + _ancestor.c.id.label('ancestor_lot_id'), + None, + ] + ) + .select_from(_ancestor) + .select_from(lot_device) + .where(db.text(descendants)) + ) # Components _parent_device = Device.__table__.alias(name='parent_device') """The device that has the access to the lot.""" - lot_device_component = lot_device \ - .join(_parent_device, _parent_device.c.id == LotDevice.device_id) \ - .join(Component, _parent_device.c.id == Component.parent_id) + lot_device_component = lot_device.join( + _parent_device, _parent_device.c.id == LotDevice.device_id + ).join(Component, _parent_device.c.id == Component.parent_id) """Join: Path -- Lot -- LotDevice -- ParentDevice (Device) -- Component""" - components = db.select([ - Component.id.label('device_id'), - _desc.c.id.label('parent_lot_id'), - _ancestor.c.id.label('ancestor_lot_id'), - LotDevice.device_id.label('device_parent_id'), - ]).select_from(_ancestor).select_from(lot_device_component).where(db.text(descendants)) + components = ( + db.select( + [ + Component.id.label('device_id'), + _desc.c.id.label('parent_lot_id'), + _ancestor.c.id.label('ancestor_lot_id'), + LotDevice.device_id.label('device_parent_id'), + ] + ) + .select_from(_ancestor) + .select_from(lot_device_component) + .where(db.text(descendants)) + ) __table__ = create_view('lot_device_descendants', devices.union(components)) class LotParent(db.Model): - i = f.index(Path.path, db.func.text2ltree(f.replace(exp.cast(Path.lot_id, TEXT), '-', '_'))) + i = f.index( + Path.path, db.func.text2ltree(f.replace(exp.cast(Path.lot_id, TEXT), '-', '_')) + ) __table__ = create_view( 'lot_parent', - db.select([ - Path.lot_id.label('child_id'), - exp.cast(f.replace(exp.cast(f.subltree(Path.path, i - 1, i), TEXT), '_', '-'), - UUID).label('parent_id') - ]).select_from(Path).where(i > 0), + db.select( + [ + Path.lot_id.label('child_id'), + exp.cast( + f.replace( + exp.cast(f.subltree(Path.path, i - 1, i), TEXT), '_', '-' + ), + UUID, + ).label('parent_id'), + ] + ) + .select_from(Path) + .where(i > 0), ) diff --git a/ereuse_devicehub/resources/lot/views.py b/ereuse_devicehub/resources/lot/views.py index b99e622f..117d3209 100644 --- a/ereuse_devicehub/resources/lot/views.py +++ b/ereuse_devicehub/resources/lot/views.py @@ -1,20 +1,22 @@ import uuid -from sqlalchemy.util import OrderedSet from collections import deque from enum import Enum from typing import Dict, List, Set, Union import marshmallow as ma -from flask import Response, jsonify, request, g -from marshmallow import Schema as MarshmallowSchema, fields as f +from flask import Response, g, jsonify, request +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 from ereuse_devicehub.query import things_response -from ereuse_devicehub.resources.device.models import Device, Computer -from ereuse_devicehub.resources.action.models import Trade, Confirm, Revoke +from ereuse_devicehub.resources.action.models import Confirm, Revoke, Trade +from ereuse_devicehub.resources.device.models import Computer, Device from ereuse_devicehub.resources.lot.models import Lot, Path @@ -27,6 +29,7 @@ class LotView(View): """Allowed arguments for the ``find`` method (GET collection) endpoint """ + format = EnumField(LotFormat, missing=None) search = f.Str(missing=None) type = f.Str(missing=None) @@ -42,12 +45,26 @@ class LotView(View): return ret def patch(self, id): - patch_schema = self.resource_def.SCHEMA(only=( - 'name', 'description', 'transfer_state', 'receiver_address', 'amount', 'devices', - 'owner_address'), partial=True) + patch_schema = self.resource_def.SCHEMA( + only=( + 'name', + 'description', + 'transfer_state', + 'receiver_address', + 'amount', + 'devices', + 'owner_address', + ), + partial=True, + ) l = request.get_json(schema=patch_schema) lot = Lot.query.filter_by(id=id).one() - device_fields = ['transfer_state', 'receiver_address', 'amount', 'owner_address'] + device_fields = [ + 'transfer_state', + 'receiver_address', + 'amount', + 'owner_address', + ] computers = [x for x in lot.all_devices if isinstance(x, Computer)] for key, value in l.items(): setattr(lot, key, value) @@ -84,7 +101,7 @@ class LotView(View): ret = { 'items': {l['id']: l for l in lots}, 'tree': self.ui_tree(), - 'url': request.path + 'url': request.path, } else: query = Lot.query @@ -95,15 +112,28 @@ class LotView(View): lots = query.paginate(per_page=6 if args['search'] else query.count()) return things_response( self.schema.dump(lots.items, many=True, nested=2), - lots.page, lots.per_page, lots.total, lots.prev_num, lots.next_num + lots.page, + lots.per_page, + lots.total, + lots.prev_num, + lots.next_num, ) return jsonify(ret) def visibility_filter(self, query): - query = query.outerjoin(Trade) \ - .filter(or_(Trade.user_from == g.user, - Trade.user_to == g.user, - Lot.owner_id == g.user.id)) + query = ( + query.outerjoin(Trade) + .outerjoin(Transfer) + .filter( + or_( + Trade.user_from == g.user, + Trade.user_to == g.user, + Lot.owner_id == g.user.id, + Transfer.user_from == g.user, + Transfer.user_to == g.user, + ) + ) + ) return query def type_filter(self, query, args): @@ -111,13 +141,23 @@ class LotView(View): # temporary if lot_type == "temporary": - return query.filter(Lot.trade == None) + return query.filter(Lot.trade == None).filter(Lot.transfer == None) if lot_type == "incoming": - return query.filter(Lot.trade and Trade.user_to == g.user) + return query.filter( + or_( + Lot.trade and Trade.user_to == g.user, + Lot.transfer and Transfer.user_to == g.user, + ) + ).all() if lot_type == "outgoing": - return query.filter(Lot.trade and Trade.user_from == g.user) + return query.filter( + or_( + Lot.trade and Trade.user_from == g.user, + Lot.transfer and Transfer.user_from == g.user, + ) + ).all() return query @@ -152,10 +192,7 @@ class LotView(View): # does lot_id exist already in node? node = next(part for part in nodes if lot_id == part['id']) except StopIteration: - node = { - 'id': lot_id, - 'nodes': [] - } + node = {'id': lot_id, 'nodes': []} nodes.append(node) if path: cls._p(node['nodes'], path) @@ -175,15 +212,17 @@ class LotView(View): class LotBaseChildrenView(View): """Base class for adding / removing children devices and - lots from a lot. - """ + lots from a lot. + """ def __init__(self, definition: 'Resource', **kw) -> None: super().__init__(definition, **kw) self.list_args = self.ListArgs() def get_ids(self) -> Set[uuid.UUID]: - args = self.QUERY_PARSER.parse(self.list_args, request, locations=('querystring',)) + args = self.QUERY_PARSER.parse( + self.list_args, request, locations=('querystring',) + ) return set(args['id']) def get_lot(self, id: uuid.UUID) -> Lot: @@ -247,8 +286,9 @@ class LotDeviceView(LotBaseChildrenView): if not ids: return - devices = set(Device.query.filter(Device.id.in_(ids)).filter( - Device.owner == g.user)) + devices = set( + Device.query.filter(Device.id.in_(ids)).filter(Device.owner == g.user) + ) lot.devices.update(devices) @@ -271,8 +311,9 @@ class LotDeviceView(LotBaseChildrenView): txt = 'This is not your lot' raise ma.ValidationError(txt) - devices = set(Device.query.filter(Device.id.in_(ids)).filter( - Device.owner_id == g.user.id)) + devices = set( + Device.query.filter(Device.id.in_(ids)).filter(Device.owner_id == g.user.id) + ) lot.devices.difference_update(devices) @@ -311,9 +352,7 @@ def delete_from_trade(lot: Lot, devices: List): phantom = lot.trade.user_from phantom_revoke = Revoke( - action=lot.trade, - user=phantom, - devices=set(without_confirms) + action=lot.trade, user=phantom, devices=set(without_confirms) ) db.session.add(phantom_revoke) diff --git a/ereuse_devicehub/templates/inventory/device_list.html b/ereuse_devicehub/templates/inventory/device_list.html index 54376fa5..d08bc413 100644 --- a/ereuse_devicehub/templates/inventory/device_list.html +++ b/ereuse_devicehub/templates/inventory/device_list.html @@ -37,10 +37,19 @@
-

{{ lot.name }}

+
+

+ {{ lot.name }} +

+ {% if lot.transfer.code and lot.transfer.user_to and not lot.transfer.user_to.phantom %} + {{ lot.transfer.code }} {{ lot.transfer.user_to.email }} + {% elif lot.transfer.code and lot.transfer.user_from and not lot.transfer.user_from.phantom %} + {{ lot.transfer.user_from.email }} {{ lot.transfer.code }} + {% endif %} +
- {% if lot.is_temporary %} + {% if lot.is_temporary or not lot.transfer.closed %} {% if 1 == 2 %}{# #} @@ -75,9 +84,28 @@ + {% if lot.transfer %} + + {% endif %} + {% endif %}
+ {% if lot and lot.is_temporary %} + + {% endif %}
+
+
Transfer
+
+ {{ form_transfer.csrf_token }} + + {% for field in form_transfer %} + {% if field != form_transfer.csrf_token %} +
+ {% if field != form_transfer.type %} + {{ field.label(class_="form-label") }} + {% if field == form_transfer.code %} + * + {% endif %} + {{ field }} + {% if field.errors %} +

+ {% for error in field.errors %} + {{ error }}
+ {% endfor %} +

+ {% endif %} + {% endif %} +
+ {% endif %} + {% endfor %} + +
+ Cancel + +
+
+
{% endif %}
diff --git a/ereuse_devicehub/templates/inventory/new_transfer.html b/ereuse_devicehub/templates/inventory/new_transfer.html new file mode 100644 index 00000000..f3776c41 --- /dev/null +++ b/ereuse_devicehub/templates/inventory/new_transfer.html @@ -0,0 +1,73 @@ +{% extends "ereuse_devicehub/base_site.html" %} +{% block main %} + +
+

{{ title }}

+ +
+ +
+
+
+ +
+
+ +
+
{{ title }}
+ {% if form.form_errors %} +

+ {% for error in form.form_errors %} + {{ error }}
+ {% endfor %} +

+ {% endif %} +
+ +
+ {{ form.csrf_token }} + + {% for field in form %} + {% if field != form.csrf_token %} +
+ {% if field != form.type %} + {{ field.label(class_="form-label") }} + {% if field == form.code %} + * + {% endif %} + {{ field }} + {% if field.errors %} +

+ {% for error in field.errors %} + {{ error }}
+ {% endfor %} +

+ {% endif %} + {% endif %} +
+ {% endif %} + {% endfor %} + +
+ Cancel + +
+
+ +
+ +
+ +
+ +
+
+
+
+{% endblock main %} diff --git a/tests/test_basic.py b/tests/test_basic.py index f9960219..adbac7b1 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -60,6 +60,8 @@ def test_api_docs(client: Client): '/inventory/lot/{lot_id}/device/', '/inventory/lot/{lot_id}/device/add/', '/inventory/lot/{lot_id}/trade-document/add/', + '/inventory/lot/{lot_id}/transfer/{type_id}/', + '/inventory/lot/{lot_id}/transfer/', '/inventory/lot/{lot_id}/upload-snapshot/', '/inventory/tag/devices/add/', '/inventory/tag/devices/{id}/del/', diff --git a/tests/test_render_2_0.py b/tests/test_render_2_0.py index be5241a3..00784138 100644 --- a/tests/test_render_2_0.py +++ b/tests/test_render_2_0.py @@ -1084,3 +1084,94 @@ def test_wb_settings_register(user3: UserClientFlask): assert "TOKEN = " in body assert "URL = https://" in body assert "/api/inventory/" in body + + +@pytest.mark.mvp +@pytest.mark.usefixtures(conftest.app_context.__name__) +def test_create_transfer(user3: UserClientFlask): + user3.get('/inventory/lot/add/') + lot_name = 'lot1' + data = { + 'name': lot_name, + 'csrf_token': generate_csrf(), + } + user3.post('/inventory/lot/add/', data=data) + lot = Lot.query.filter_by(name=lot_name).one() + + lot_id = lot.id + uri = f'/inventory/lot/{lot_id}/transfer/incoming/' + body, status = user3.get(uri) + assert status == '200 OK' + assert 'Add new transfer' in body + assert 'Code' in body + assert 'Description' in body + assert 'Save' in body + + data = {'csrf_token': generate_csrf(), 'code': 'AAA'} + + body, status = user3.post(uri, data=data) + assert status == '200 OK' + assert 'Transfer created successfully!' in body + assert 'Delete Lot' in body + assert 'Incoming Lot' in body + + +@pytest.mark.mvp +@pytest.mark.usefixtures(conftest.app_context.__name__) +def test_edit_transfer(user3: UserClientFlask): + # create lot + user3.get('/inventory/lot/add/') + lot_name = 'lot1' + data = { + 'name': lot_name, + 'csrf_token': generate_csrf(), + } + user3.post('/inventory/lot/add/', data=data) + lot = Lot.query.filter_by(name=lot_name).one() + + # render temporary lot + lot_id = lot.id + uri = f'/inventory/lot/{lot_id}/device/' + body, status = user3.get(uri) + assert status == '200 OK' + assert 'Transfer (Open)' not in body + assert ' Delete Lot' in body + + # create new incoming lot + uri = f'/inventory/lot/{lot_id}/transfer/incoming/' + data = {'csrf_token': generate_csrf(), 'code': 'AAA'} + body, status = user3.post(uri, data=data) + assert 'Transfer (Open)' in body + assert ' Delete Lot' in body + lot = Lot.query.filter()[1] + assert lot.transfer is not None + + # edit transfer with errors + lot_id = lot.id + uri = f'/inventory/lot/{lot_id}/transfer/' + data = { + 'csrf_token': generate_csrf(), + 'code': 'AAA', + 'description': 'one one one', + 'date': datetime.datetime.now().date() + datetime.timedelta(15), + } + body, status = user3.post(uri, data=data) + assert status == '200 OK' + assert 'Transfer updated error!' in body + assert 'one one one' not in body + assert ' Delete Lot' in body + assert 'Transfer (Open)' in body + + # # edit transfer successfully + data = { + 'csrf_token': generate_csrf(), + 'code': 'AAA', + 'description': 'one one one', + 'date': datetime.datetime.now().date() - datetime.timedelta(15), + } + body, status = user3.post(uri, data=data) + assert status == '200 OK' + assert 'Transfer updated successfully!' in body + assert 'one one one' in body + assert ' Delete Lot' not in body + assert 'Transfer (Closed)' in body