diff --git a/.eslintrc.json b/.eslintrc.json index 56f4296d..a313dfa2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -28,7 +28,7 @@ "class-methods-use-this": "off", "eqeqeq": "warn", "radix": "warn", - "max-classes-per-file": ["error", 2] + "max-classes-per-file": "warn" }, "globals": { "API_URLS": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index c6c51b46..335cafa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ml). ## master ## testing +- [added] #273 Allow search/filter lots on lots management component. ## [2.1.1] - 2022-05-11 Hot fix release. diff --git a/ereuse_devicehub/inventory/forms.py b/ereuse_devicehub/inventory/forms.py index 2e48e933..f0adcb8c 100644 --- a/ereuse_devicehub/inventory/forms.py +++ b/ereuse_devicehub/inventory/forms.py @@ -1,4 +1,5 @@ import copy +import datetime import json from json.decoder import JSONDecodeError @@ -27,6 +28,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.parser.models import SnapshotsLog from ereuse_devicehub.parser.parser import ParseSnapshotLsHw from ereuse_devicehub.parser.schemas import Snapshot_lite @@ -649,6 +651,10 @@ class AllocateForm(ActionFormMixin): self.start_time.errors = ['Not a valid date value.!'] return False + if start_time > datetime.datetime.now().date(): + self.start_time.errors = ['Not a valid date value.!'] + return False + if start_time and end_time and end_time < start_time: error = ['The action cannot finish before it starts.'] self.end_time.errors = error @@ -661,23 +667,100 @@ class AllocateForm(ActionFormMixin): def check_devices(self): if self.type.data == 'Allocate': - txt = "You need deallocate before allocate this device again" - for device in self._devices: - if device.allocated: - self.devices.errors = [txt] - return False - - device.allocated = True - + return self.check_allocate() if self.type.data == 'Deallocate': - txt = "Sorry some of this devices are actually deallocate" - for device in self._devices: - if not device.allocated: + return self.check_deallocate() + return True + + def check_allocate(self): + txt = "You need deallocate before allocate this device again" + for device in self._devices: + # | Allo - Deallo | Allo - Deallo | + + allocates = [ + ac for ac in device.actions if ac.type in ['Allocate', 'Deallocate'] + ] + allocates.sort(key=lambda x: x.start_time) + allocates.reverse() + last_deallocate = None + last_allocate = None + for ac in allocates: + if ( + ac.type == 'Deallocate' + and ac.start_time.date() < self.start_time.data + ): + # allow to do the action + break + + # check if this action is between an old allocate - deallocate + if ac.type == 'Deallocate': + last_deallocate = ac + continue + + if ( + ac.type == 'Allocate' + and ac.start_time.date() > self.start_time.data + ): + last_deallocate = None + last_allocate = None + continue + + if ac.type == 'Allocate': + last_allocate = ac + + if last_allocate or not last_deallocate: self.devices.errors = [txt] return False - device.allocated = False + device.allocated = True + return True + def check_deallocate(self): + txt = "Sorry some of this devices are actually deallocate" + for device in self._devices: + allocates = [ + ac for ac in device.actions if ac.type in ['Allocate', 'Deallocate'] + ] + allocates.sort(key=lambda x: x.start_time) + allocates.reverse() + last_deallocate = None + last_allocate = None + + for ac in allocates: + # check if this action is between an old allocate - deallocate + # | Allo - Deallo | Allo - Deallo | + # | Allo | + if ( + ac.type == 'Allocate' + and ac.start_time.date() > self.start_time.data + ): + last_allocate = None + last_deallocate = None + continue + + if ac.type == 'Allocate' and not last_deallocate: + last_allocate = ac + break + + if ( + ac.type == 'Deallocate' + and ac.start_time.date() > self.start_time.data + ): + last_deallocate = ac + continue + + if ac.type == 'Deallocate': + last_allocate = None + + if last_deallocate or not last_allocate: + self.devices.errors = [txt] + return False + + if not last_deallocate and not last_allocate: + self.devices.errors = [txt] + return False + + device.allocated = False return True @@ -998,3 +1081,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 a54b06eb..344f00ef 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 @@ -48,16 +50,12 @@ class DeviceListMixin(GenericMixin): 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( @@ -67,7 +65,7 @@ class DeviceListMixin(GenericMixin): '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, @@ -402,6 +400,48 @@ class NewTradeDocumentView(View): return flask.render_template(self.template_name, **self.context) +class NewTransferView(GenericMixin): + 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(GenericMixin): + 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] @@ -628,3 +668,11 @@ devices.add_url_rule( '/snapshots//', view_func=SnapshotDetailView.as_view('snapshot_detail'), ) +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/migrations/versions/51439cf24be8_change_trade_action.py b/ereuse_devicehub/migrations/versions/51439cf24be8_change_trade_action.py index 778f484b..9fdae40d 100644 --- a/ereuse_devicehub/migrations/versions/51439cf24be8_change_trade_action.py +++ b/ereuse_devicehub/migrations/versions/51439cf24be8_change_trade_action.py @@ -5,14 +5,11 @@ 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 +import sqlalchemy as sa +from alembic import context, op from sqlalchemy.dialects import postgresql - # revision identifiers, used by Alembic. revision = '51439cf24be8' down_revision = '21afd375a654' @@ -36,59 +33,197 @@ def upgrade_data(): def upgrade(): - ## Trade - currency = sa.Enum('AFN', 'ARS', 'AWG', 'AUD', 'AZN', 'BSD', 'BBD', 'BDT', 'BYR', 'BZD', 'BMD', - 'BOB', 'BAM', 'BWP', 'BGN', 'BRL', 'BND', 'KHR', 'CAD', 'KYD', 'CLP', 'CNY', - 'COP', 'CRC', 'HRK', 'CUP', 'CZK', 'DKK', 'DOP', 'XCD', 'EGP', 'SVC', 'EEK', - 'EUR', 'FKP', 'FJD', 'GHC', 'GIP', 'GTQ', 'GGP', 'GYD', 'HNL', 'HKD', 'HUF', - 'ISK', 'INR', 'IDR', 'IRR', 'IMP', 'ILS', 'JMD', 'JPY', 'JEP', 'KZT', 'KPW', - 'KRW', 'KGS', 'LAK', 'LVL', 'LBP', 'LRD', 'LTL', 'MKD', 'MYR', 'MUR', 'MXN', - 'MNT', 'MZN', 'NAD', 'NPR', 'ANG', 'NZD', 'NIO', 'NGN', 'NOK', 'OMR', 'PKR', - 'PAB', 'PYG', 'PEN', 'PHP', 'PLN', 'QAR', 'RON', 'RUB', 'SHP', 'SAR', 'RSD', - 'SCR', 'SGD', 'SBD', 'SOS', 'ZAR', 'LKR', 'SEK', 'CHF', 'SRD', 'SYP', 'TWD', - 'THB', 'TTD', 'TRY', 'TRL', 'TVD', 'UAH', 'GBP', 'USD', 'UYU', 'UZS', 'VEF', name='currency', create_type=False, checkfirst=True, schema=f'{get_inv()}') - + # Trade + currency = sa.Enum( + 'AFN', + 'ARS', + 'AWG', + 'AUD', + 'AZN', + 'BSD', + 'BBD', + 'BDT', + 'BYR', + 'BZD', + 'BMD', + 'BOB', + 'BAM', + 'BWP', + 'BGN', + 'BRL', + 'BND', + 'KHR', + 'CAD', + 'KYD', + 'CLP', + 'CNY', + 'COP', + 'CRC', + 'HRK', + 'CUP', + 'CZK', + 'DKK', + 'DOP', + 'XCD', + 'EGP', + 'SVC', + 'EEK', + 'EUR', + 'FKP', + 'FJD', + 'GHC', + 'GIP', + 'GTQ', + 'GGP', + 'GYD', + 'HNL', + 'HKD', + 'HUF', + 'ISK', + 'INR', + 'IDR', + 'IRR', + 'IMP', + 'ILS', + 'JMD', + 'JPY', + 'JEP', + 'KZT', + 'KPW', + 'KRW', + 'KGS', + 'LAK', + 'LVL', + 'LBP', + 'LRD', + 'LTL', + 'MKD', + 'MYR', + 'MUR', + 'MXN', + 'MNT', + 'MZN', + 'NAD', + 'NPR', + 'ANG', + 'NZD', + 'NIO', + 'NGN', + 'NOK', + 'OMR', + 'PKR', + 'PAB', + 'PYG', + 'PEN', + 'PHP', + 'PLN', + 'QAR', + 'RON', + 'RUB', + 'SHP', + 'SAR', + 'RSD', + 'SCR', + 'SGD', + 'SBD', + 'SOS', + 'ZAR', + 'LKR', + 'SEK', + 'CHF', + 'SRD', + 'SYP', + 'TWD', + 'THB', + 'TTD', + 'TRY', + 'TRL', + 'TVD', + 'UAH', + 'GBP', + 'USD', + 'UYU', + 'UZS', + 'VEF', + name='currency', + create_type=False, + checkfirst=True, + ) op.drop_table('trade', schema=f'{get_inv()}') - op.create_table('trade', - sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('price', sa.Float(decimal_return_scale=4), nullable=True), - sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=True), - sa.Column('date', sa.TIMESTAMP(timezone=True), nullable=True), - sa.Column('user_from_id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('user_to_id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('document_id', citext.CIText(), nullable=True), - sa.Column('confirm', sa.Boolean(), nullable=True), - sa.Column('code', citext.CIText(), default='', nullable=True, - comment = "This code is used for traceability"), - sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), - sa.ForeignKeyConstraint(['user_from_id'], ['common.user.id'], ), - sa.ForeignKeyConstraint(['user_to_id'], ['common.user.id'], ), - sa.ForeignKeyConstraint(['lot_id'], [f'{get_inv()}.lot.id'], ), - sa.PrimaryKeyConstraint('id'), - schema=f'{get_inv()}' - ) + op.create_table( + 'trade', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('price', sa.Float(decimal_return_scale=4), nullable=True), + sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('date', sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column('user_from_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('user_to_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('document_id', citext.CIText(), nullable=True), + sa.Column('confirm', sa.Boolean(), nullable=True), + sa.Column( + 'code', + citext.CIText(), + default='', + nullable=True, + comment="This code is used for traceability", + ), + sa.ForeignKeyConstraint( + ['id'], + [f'{get_inv()}.action.id'], + ), + sa.ForeignKeyConstraint( + ['user_from_id'], + ['common.user.id'], + ), + sa.ForeignKeyConstraint( + ['user_to_id'], + ['common.user.id'], + ), + sa.ForeignKeyConstraint( + ['lot_id'], + [f'{get_inv()}.lot.id'], + ), + sa.PrimaryKeyConstraint('id'), + schema=f'{get_inv()}', + ) - op.add_column("trade", sa.Column("currency", currency, nullable=False), schema=f'{get_inv()}') + op.add_column( + "trade", sa.Column("currency", currency, nullable=False), schema=f'{get_inv()}' + ) + op.create_table( + 'confirm', + 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()}', + ) - op.create_table('confirm', - 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()}' - ) - - ## 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), - schema='common') + # 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), + schema='common', + ) upgrade_data() @@ -99,28 +234,57 @@ def upgrade(): def downgrade(): op.drop_table('confirm', schema=f'{get_inv()}') op.drop_table('trade', schema=f'{get_inv()}') - op.create_table('trade', - sa.Column('shipping_date', sa.TIMESTAMP(timezone=True), nullable=True, - comment='When are the devices going to be ready \n \ - for shipping?\n '), - sa.Column('invoice_number', citext.CIText(), nullable=True, - comment='The id of the invoice so they can be linked.'), - sa.Column('price_id', postgresql.UUID(as_uuid=True), nullable=True, - comment='The price set for this trade. \n \ + op.create_table( + 'trade', + sa.Column( + 'shipping_date', + sa.TIMESTAMP(timezone=True), + nullable=True, + comment='When are the devices going to be ready \n \ + for shipping?\n ', + ), + sa.Column( + 'invoice_number', + citext.CIText(), + nullable=True, + comment='The id of the invoice so they can be linked.', + ), + sa.Column( + 'price_id', + postgresql.UUID(as_uuid=True), + nullable=True, + comment='The price set for this trade. \n \ If no price is set it is supposed that the trade was\n \ - not payed, usual in donations.\n '), - sa.Column('to_id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('confirms_id', postgresql.UUID(as_uuid=True), nullable=True, - comment='An organize action that this association confirms. \ + not payed, usual in donations.\n ', + ), + sa.Column('to_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + 'confirms_id', + postgresql.UUID(as_uuid=True), + nullable=True, + comment='An organize action that this association confirms. \ \n \n For example, a ``Sell`` or ``Rent``\n \ - can confirm a ``Reserve`` action.\n '), - sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), - sa.ForeignKeyConstraint(['confirms_id'], [f'{get_inv()}.organize.id'], ), - sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), - sa.ForeignKeyConstraint(['price_id'], [f'{get_inv()}.price.id'], ), - sa.ForeignKeyConstraint(['to_id'], [f'{get_inv()}.agent.id'], ), - sa.PrimaryKeyConstraint('id'), - schema=f'{get_inv()}' - ) + can confirm a ``Reserve`` action.\n ', + ), + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ['confirms_id'], + [f'{get_inv()}.organize.id'], + ), + sa.ForeignKeyConstraint( + ['id'], + [f'{get_inv()}.action.id'], + ), + sa.ForeignKeyConstraint( + ['price_id'], + [f'{get_inv()}.price.id'], + ), + sa.ForeignKeyConstraint( + ['to_id'], + [f'{get_inv()}.agent.id'], + ), + sa.PrimaryKeyConstraint('id'), + schema=f'{get_inv()}', + ) op.drop_column('user', 'active', schema='common') op.drop_column('user', 'phantom', schema='common') diff --git a/ereuse_devicehub/migrations/versions/b4bd1538bad5_update_live.py b/ereuse_devicehub/migrations/versions/b4bd1538bad5_update_live.py index f144bc58..ffaa57ea 100644 --- a/ereuse_devicehub/migrations/versions/b4bd1538bad5_update_live.py +++ b/ereuse_devicehub/migrations/versions/b4bd1538bad5_update_live.py @@ -5,12 +5,10 @@ Revises: 3eb50297c365 Create Date: 2020-12-29 20:19:46.981207 """ -from alembic import context -from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql import teal - +from alembic import context, op +from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = 'b4bd1538bad5' @@ -25,33 +23,71 @@ def get_inv(): raise ValueError("Inventory value is not specified") return INV + def upgrade(): + # op.execute("COMMIT") + op.execute("ALTER TYPE snapshotsoftware ADD VALUE 'WorkbenchDesktop'") + SOFTWARE = sa.Enum( + 'Workbench', + 'WorkbenchAndroid', + 'AndroidApp', + 'Web', + 'DesktopApp', + 'WorkbenchDesktop', + name='snapshotsoftware', + create_type=False, + checkfirst=True, + ) + # Live action op.drop_table('live', schema=f'{get_inv()}') - op.create_table('live', + op.create_table( + 'live', sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('serial_number', sa.Unicode(), nullable=True, - comment='The serial number of the Hard Disk in lower case.'), + sa.Column( + 'serial_number', + sa.Unicode(), + nullable=True, + comment='The serial number of the Hard Disk in lower case.', + ), sa.Column('usage_time_hdd', sa.Interval(), nullable=True), sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('software_version', teal.db.StrictVersionType(length=32), nullable=False), - sa.Column('licence_version', teal.db.StrictVersionType(length=32), nullable=False), - sa.Column('software', sa.Enum('Workbench', 'WorkbenchAndroid', 'AndroidApp', 'Web', - 'DesktopApp', 'WorkbenchDesktop', name='snapshotsoftware'), nullable=False), - sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), + sa.Column( + 'software_version', teal.db.StrictVersionType(length=32), nullable=False + ), + sa.Column( + 'licence_version', teal.db.StrictVersionType(length=32), nullable=False + ), + sa.Column('software', SOFTWARE, nullable=False), + sa.ForeignKeyConstraint( + ['id'], + [f'{get_inv()}.action.id'], + ), sa.PrimaryKeyConstraint('id'), - schema=f'{get_inv()}' + schema=f'{get_inv()}', ) + def downgrade(): op.drop_table('live', schema=f'{get_inv()}') - op.create_table('live', + op.create_table( + 'live', sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), - sa.Column('serial_number', sa.Unicode(), nullable=True, - comment='The serial number of the Hard Disk in lower case.'), + sa.Column( + 'serial_number', + sa.Unicode(), + nullable=True, + comment='The serial number of the Hard Disk in lower case.', + ), sa.Column('usage_time_hdd', sa.Interval(), nullable=True), sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False), - sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), + sa.ForeignKeyConstraint( + ['id'], + [f'{get_inv()}.action.id'], + ), sa.PrimaryKeyConstraint('id'), - schema=f'{get_inv()}' + schema=f'{get_inv()}', + ) + op.execute( + "select e.enumlabel FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid WHERE t.typname = 'snapshotsoftware'" ) 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/device/models.py b/ereuse_devicehub/resources/device/models.py index 88c02e1b..18487ba5 100644 --- a/ereuse_devicehub/resources/device/models.py +++ b/ereuse_devicehub/resources/device/models.py @@ -1,6 +1,5 @@ import copy import pathlib -import time from contextlib import suppress from fractions import Fraction from itertools import chain @@ -406,7 +405,7 @@ class Device(Thing): def tradings(self): return {str(x.id): self.trading(x.lot) for x in self.actions if x.t == 'Trade'} - def trading(self, lot, simple=None): + def trading(self, lot, simple=None): # noqa: C901 """The trading state, or None if no Trade action has ever been performed to this device. This extract the posibilities for to do. This method is performed for show in the web. diff --git a/ereuse_devicehub/resources/lot/models.py b/ereuse_devicehub/resources/lot/models.py index a8e957e2..ef6577a8 100644 --- a/ereuse_devicehub/resources/lot/models.py +++ b/ereuse_devicehub/resources/lot/models.py @@ -5,7 +5,6 @@ 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 @@ -120,18 +119,24 @@ 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): - if hasattr(self, 'trade'): + 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): - if hasattr(self, 'trade'): - return self.trade.user_to == g.user + if self.trade: + return self.trade.user_from == g.user + if self.transfer: + return self.transfer.user_from == g.user + return False @classmethod 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/static/js/main.js b/ereuse_devicehub/static/js/main.js index 996d8b23..474658d5 100644 --- a/ereuse_devicehub/static/js/main.js +++ b/ereuse_devicehub/static/js/main.js @@ -1,353 +1,357 @@ -/** -* Template Name: NiceAdmin - v2.2.0 -* Template URL: https://bootstrapmade.com/nice-admin-bootstrap-admin-html-template/ -* Author: BootstrapMade.com -* License: https://bootstrapmade.com/license/ -*/ -(function () { - "use strict"; - - /** - * Easy selector helper function - */ - const select = (el, all = false) => { - el = el.trim() - if (all) { - return [...document.querySelectorAll(el)] - } - return document.querySelector(el) - - } - - /** - * Easy event listener function - */ - const on = (type, el, listener, all = false) => { - if (all) { - select(el, all).forEach(e => e.addEventListener(type, listener)) - } else { - select(el, all).addEventListener(type, listener) - } - } - - /** - * Easy on scroll event listener - */ - const onscroll = (el, listener) => { - el.addEventListener("scroll", listener) - } - - /** - * Sidebar toggle - */ - if (select(".toggle-sidebar-btn")) { - on("click", ".toggle-sidebar-btn", (e) => { - select("body").classList.toggle("toggle-sidebar") - }) - } - - /** - * Search bar toggle - */ - if (select(".search-bar-toggle")) { - on("click", ".search-bar-toggle", (e) => { - select(".search-bar").classList.toggle("search-bar-show") - }) - } - - /** - * Navbar links active state on scroll - */ - const navbarlinks = select("#navbar .scrollto", true) - const navbarlinksActive = () => { - const position = window.scrollY + 200 - navbarlinks.forEach(navbarlink => { - if (!navbarlink.hash) return - const section = select(navbarlink.hash) - if (!section) return - if (position >= section.offsetTop && position <= (section.offsetTop + section.offsetHeight)) { - navbarlink.classList.add("active") - } else { - navbarlink.classList.remove("active") - } - }) - } - window.addEventListener("load", navbarlinksActive) - onscroll(document, navbarlinksActive) - - /** - * Toggle .header-scrolled class to #header when page is scrolled - */ - const selectHeader = select("#header") - if (selectHeader) { - const headerScrolled = () => { - if (window.scrollY > 100) { - selectHeader.classList.add("header-scrolled") - } else { - selectHeader.classList.remove("header-scrolled") - } - } - window.addEventListener("load", headerScrolled) - onscroll(document, headerScrolled) - } - - /** - * Back to top button - */ - const backtotop = select(".back-to-top") - if (backtotop) { - const toggleBacktotop = () => { - if (window.scrollY > 100) { - backtotop.classList.add("active") - } else { - backtotop.classList.remove("active") - } - } - window.addEventListener("load", toggleBacktotop) - onscroll(document, toggleBacktotop) - } - - /** - * Initiate tooltips - */ - const tooltipTriggerList = [].slice.call(document.querySelectorAll("[data-bs-toggle=\"tooltip\"]")) - const tooltipList = tooltipTriggerList.map((tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl)) - - /** - * Initiate quill editors - */ - if (select(".quill-editor-default")) { - new Quill(".quill-editor-default", { - theme: "snow" - }); - } - - if (select(".quill-editor-bubble")) { - new Quill(".quill-editor-bubble", { - theme: "bubble" - }); - } - - if (select(".quill-editor-full")) { - new Quill(".quill-editor-full", { - modules: { - toolbar: [ - [{ - font: [] - }, { - size: [] - }], - ["bold", "italic", "underline", "strike"], - [{ - color: [] - }, - { - background: [] - } - ], - [{ - script: "super" - }, - { - script: "sub" - } - ], - [{ - list: "ordered" - }, - { - list: "bullet" - }, - { - indent: "-1" - }, - { - indent: "+1" - } - ], - ["direction", { - align: [] - }], - ["link", "image", "video"], - ["clean"] - ] - }, - theme: "snow" - }); - } - - /** - * Initiate Bootstrap validation check - */ - const needsValidation = document.querySelectorAll(".needs-validation") - - Array.prototype.slice.call(needsValidation) - .forEach((form) => { - form.addEventListener("submit", (event) => { - if (!form.checkValidity()) { - event.preventDefault() - event.stopPropagation() - } - - form.classList.add("was-validated") - }, false) - }) - - /** - * Initiate Datatables - */ - const datatables = select(".datatable", true) - datatables.forEach(datatable => { - new simpleDatatables.DataTable(datatable); - }) - - /** - * Autoresize echart charts - */ - const mainContainer = select("#main"); - if (mainContainer) { - setTimeout(() => { - new ResizeObserver(() => { - select(".echart", true).forEach(getEchart => { - echarts.getInstanceByDom(getEchart).resize(); - }) - }).observe(mainContainer); - }, 200); - } - - /** - * Avoid hide dropdown when user clicked inside - */ - document.getElementById("dropDownLotsSelector").addEventListener("click", event => { - event.stopPropagation(); - }) - - /** - * Search form functionality - */ - window.addEventListener("DOMContentLoaded", () => { - const searchForm = document.getElementById("SearchForm") - const inputSearch = document.querySelector("#SearchForm > input") - const doSearch = true - - searchForm.addEventListener("submit", (event) => { - event.preventDefault(); - }) - - let timeoutHandler = setTimeout(() => { }, 1) - const dropdownList = document.getElementById("dropdown-search-list") - const defaultEmptySearch = document.getElementById("dropdown-search-list").innerHTML - - - inputSearch.addEventListener("input", (e) => { - clearTimeout(timeoutHandler) - const searchText = e.target.value - if (searchText == "") { - document.getElementById("dropdown-search-list").innerHTML = defaultEmptySearch; - return - } - - let resultCount = 0; - function searchCompleted() { - resultCount++; - setTimeout(() => { - if (resultCount == 2 && document.getElementById("dropdown-search-list").children.length == 2) { - document.getElementById("dropdown-search-list").innerHTML = ` - ` - } - }, 100) - } - - timeoutHandler = setTimeout(async () => { - dropdownList.innerHTML = ` - - `; - - - try { - Api.search_device(searchText.toUpperCase()).then(devices => { - dropdownList.querySelector("#deviceSearchLoader").style = "display: none" - - for (let i = 0; i < devices.length; i++) { - const device = devices[i]; - - // See: ereuse_devicehub/resources/device/models.py - const verboseName = `${device.type} ${device.manufacturer} ${device.model}` - - const templateString = ` -
  • - - - ${verboseName} - ${device.devicehubID} - -
  • `; - dropdownList.innerHTML += templateString - if (i == 4) { // Limit to 4 resullts - break; - } - } - - searchCompleted(); - }) - } catch (error) { - dropdownList.innerHTML += ` - `; - console.log(error); - } - - try { - Api.get_lots().then(lots => { - dropdownList.querySelector("#lotSearchLoader").style = "display: none" - for (let i = 0; i < lots.length; i++) { - const lot = lots[i]; - if (lot.name.toUpperCase().includes(searchText.toUpperCase())) { - const templateString = ` -
  • - - - ${lot.name} - -
  • `; - dropdownList.innerHTML += templateString - if (i == 4) { // Limit to 4 resullts - break; - } - } - } - searchCompleted(); - }) - - } catch (error) { - dropdownList.innerHTML += ` - `; - console.log(error); - } - }, 1000) - }) - }) - -})(); +/** +* Template Name: NiceAdmin - v2.2.0 +* Template URL: https://bootstrapmade.com/nice-admin-bootstrap-admin-html-template/ +* Author: BootstrapMade.com +* License: https://bootstrapmade.com/license/ +*/ +(function () { + "use strict"; + + /** + * Easy selector helper function + */ + const select = (el, all = false) => { + el = el.trim() + if (all) { + return [...document.querySelectorAll(el)] + } + return document.querySelector(el) + + } + + /** + * Easy event listener function + */ + const on = (type, el, listener, all = false) => { + if (all) { + select(el, all).forEach(e => e.addEventListener(type, listener)) + } else { + select(el, all).addEventListener(type, listener) + } + } + + /** + * Easy on scroll event listener + */ + const onscroll = (el, listener) => { + el.addEventListener("scroll", listener) + } + + /** + * Sidebar toggle + */ + if (select(".toggle-sidebar-btn")) { + on("click", ".toggle-sidebar-btn", (e) => { + select("body").classList.toggle("toggle-sidebar") + }) + } + + /** + * Search bar toggle + */ + if (select(".search-bar-toggle")) { + on("click", ".search-bar-toggle", (e) => { + select(".search-bar").classList.toggle("search-bar-show") + }) + } + + /** + * Navbar links active state on scroll + */ + const navbarlinks = select("#navbar .scrollto", true) + const navbarlinksActive = () => { + const position = window.scrollY + 200 + navbarlinks.forEach(navbarlink => { + if (!navbarlink.hash) return + const section = select(navbarlink.hash) + if (!section) return + if (position >= section.offsetTop && position <= (section.offsetTop + section.offsetHeight)) { + navbarlink.classList.add("active") + } else { + navbarlink.classList.remove("active") + } + }) + } + window.addEventListener("load", navbarlinksActive) + onscroll(document, navbarlinksActive) + + /** + * Toggle .header-scrolled class to #header when page is scrolled + */ + const selectHeader = select("#header") + if (selectHeader) { + const headerScrolled = () => { + if (window.scrollY > 100) { + selectHeader.classList.add("header-scrolled") + } else { + selectHeader.classList.remove("header-scrolled") + } + } + window.addEventListener("load", headerScrolled) + onscroll(document, headerScrolled) + } + + /** + * Back to top button + */ + const backtotop = select(".back-to-top") + if (backtotop) { + const toggleBacktotop = () => { + if (window.scrollY > 100) { + backtotop.classList.add("active") + } else { + backtotop.classList.remove("active") + } + } + window.addEventListener("load", toggleBacktotop) + onscroll(document, toggleBacktotop) + } + + /** + * Initiate tooltips + */ + const tooltipTriggerList = [].slice.call(document.querySelectorAll("[data-bs-toggle=\"tooltip\"]")) + const tooltipList = tooltipTriggerList.map((tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl)) + + /** + * Initiate quill editors + */ + if (select(".quill-editor-default")) { + new Quill(".quill-editor-default", { + theme: "snow" + }); + } + + if (select(".quill-editor-bubble")) { + new Quill(".quill-editor-bubble", { + theme: "bubble" + }); + } + + if (select(".quill-editor-full")) { + new Quill(".quill-editor-full", { + modules: { + toolbar: [ + [{ + font: [] + }, { + size: [] + }], + ["bold", "italic", "underline", "strike"], + [{ + color: [] + }, + { + background: [] + } + ], + [{ + script: "super" + }, + { + script: "sub" + } + ], + [{ + list: "ordered" + }, + { + list: "bullet" + }, + { + indent: "-1" + }, + { + indent: "+1" + } + ], + ["direction", { + align: [] + }], + ["link", "image", "video"], + ["clean"] + ] + }, + theme: "snow" + }); + } + + /** + * Initiate Bootstrap validation check + */ + const needsValidation = document.querySelectorAll(".needs-validation") + + Array.prototype.slice.call(needsValidation) + .forEach((form) => { + form.addEventListener("submit", (event) => { + if (!form.checkValidity()) { + event.preventDefault() + event.stopPropagation() + } + + form.classList.add("was-validated") + }, false) + }) + + /** + * Initiate Datatables + */ + const datatables = select(".datatable", true) + datatables.forEach(datatable => { + new simpleDatatables.DataTable(datatable); + }) + + /** + * Autoresize echart charts + */ + const mainContainer = select("#main"); + if (mainContainer) { + setTimeout(() => { + new ResizeObserver(() => { + select(".echart", true).forEach(getEchart => { + echarts.getInstanceByDom(getEchart).resize(); + }) + }).observe(mainContainer); + }, 200); + } + + /** + * Avoid hide dropdown when user clicked inside + */ + const dropdownLotSelector = document.getElementById("dropDownLotsSelector") + if (dropdownLotSelector != null) { // If exists selector it will set click event + dropdownLotSelector.addEventListener("click", event => { + event.stopPropagation(); + }) + } + + + /** + * Search form functionality + */ + window.addEventListener("DOMContentLoaded", () => { + const searchForm = document.getElementById("SearchForm") + const inputSearch = document.querySelector("#SearchForm > input") + const doSearch = true + + searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + }) + + let timeoutHandler = setTimeout(() => { }, 1) + const dropdownList = document.getElementById("dropdown-search-list") + const defaultEmptySearch = document.getElementById("dropdown-search-list").innerHTML + + + inputSearch.addEventListener("input", (e) => { + clearTimeout(timeoutHandler) + const searchText = e.target.value + if (searchText == "") { + document.getElementById("dropdown-search-list").innerHTML = defaultEmptySearch; + return + } + + let resultCount = 0; + function searchCompleted() { + resultCount++; + setTimeout(() => { + if (resultCount == 2 && document.getElementById("dropdown-search-list").children.length == 2) { + document.getElementById("dropdown-search-list").innerHTML = ` + ` + } + }, 100) + } + + timeoutHandler = setTimeout(async () => { + dropdownList.innerHTML = ` + + `; + + + try { + Api.search_device(searchText.toUpperCase()).then(devices => { + dropdownList.querySelector("#deviceSearchLoader").style = "display: none" + + for (let i = 0; i < devices.length; i++) { + const device = devices[i]; + + // See: ereuse_devicehub/resources/device/models.py + const verboseName = `${device.type} ${device.manufacturer} ${device.model}` + + const templateString = ` +
  • + + + ${verboseName} + ${device.devicehubID} + +
  • `; + dropdownList.innerHTML += templateString + if (i == 4) { // Limit to 4 resullts + break; + } + } + + searchCompleted(); + }) + } catch (error) { + dropdownList.innerHTML += ` + `; + console.log(error); + } + + try { + Api.get_lots().then(lots => { + dropdownList.querySelector("#lotSearchLoader").style = "display: none" + for (let i = 0; i < lots.length; i++) { + const lot = lots[i]; + if (lot.name.toUpperCase().includes(searchText.toUpperCase())) { + const templateString = ` +
  • + + + ${lot.name} + +
  • `; + dropdownList.innerHTML += templateString + if (i == 4) { // Limit to 4 resullts + break; + } + } + } + searchCompleted(); + }) + + } catch (error) { + dropdownList.innerHTML += ` + `; + console.log(error); + } + }, 1000) + }) + }) + +})(); diff --git a/ereuse_devicehub/static/js/main_inventory.build.js b/ereuse_devicehub/static/js/main_inventory.build.js index 48ab00a7..34dd7bfa 100644 --- a/ereuse_devicehub/static/js/main_inventory.build.js +++ b/ereuse_devicehub/static/js/main_inventory.build.js @@ -1,5 +1,7 @@ "use strict"; +function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } + function _classStaticPrivateFieldSpecGet(receiver, classConstructor, descriptor) { _classCheckPrivateStaticAccess(receiver, classConstructor); _classCheckPrivateStaticFieldDescriptor(descriptor, "get"); return _classApplyDescriptorGet(receiver, descriptor); } function _classCheckPrivateStaticFieldDescriptor(descriptor, action) { if (descriptor === undefined) { throw new TypeError("attempted to " + action + " private static field before its declaration"); } } @@ -328,11 +330,59 @@ function export_file(type_file) { $("#exportAlertModal").click(); } } + +class lotsSearcher { + static enable() { + if (this.lotsSearchElement) this.lotsSearchElement.disabled = false; + } + + static disable() { + if (this.lotsSearchElement) this.lotsSearchElement.disabled = true; + } + /** + * do search when lot change in the search input + */ + + + static doSearch(inputSearch) { + const lots = this.getListLots(); + + for (let i = 0; i < lots.length; i++) { + if (lot.innerText.toLowerCase().includes(inputSearch.toLowerCase())) { + lot.parentElement.style.display = ""; + } else { + lot.parentElement.style.display = "none"; + } + } + } + +} + +_defineProperty(lotsSearcher, "lots", []); + +_defineProperty(lotsSearcher, "lotsSearchElement", null); + +_defineProperty(lotsSearcher, "getListLots", () => { + let lotsList = document.getElementById("LotsSelector"); + + if (lotsList) { + // Apply filter to get only labels + return Array.from(lotsList.children).filter(item => item.querySelector("label")); + } + + return []; +}); + +document.addEventListener("DOMContentLoaded", () => { + lotsSearcher.lotsSearchElement = document.getElementById("lots-search"); + lotsSearcher.lotsSearchElement.addEventListener("input", e => { + lotsSearcher.doSearch(e.target.value); + }); +}); /** * Reactive lots button */ - async function processSelectedDevices() { class Actions { constructor() { @@ -584,6 +634,7 @@ async function processSelectedDevices() { document.getElementById("ApplyDeviceLots").classList.add("disabled"); try { + lotsSearcher.disable(); listHTML.html("
  • "); const selectedDevices = await Api.get_devices(selectedDevicesID); let lots = await Api.get_lots(); @@ -614,6 +665,7 @@ async function processSelectedDevices() { listHTML.html(""); lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions)); + lotsSearcher.enable(); } catch (error) { console.log(error); listHTML.html("
  • Error feching devices and lots
    (see console for more details)
  • "); diff --git a/ereuse_devicehub/static/js/main_inventory.js b/ereuse_devicehub/static/js/main_inventory.js index 7a2ecc93..bfd88858 100644 --- a/ereuse_devicehub/static/js/main_inventory.js +++ b/ereuse_devicehub/static/js/main_inventory.js @@ -93,7 +93,7 @@ const selectorController = (action) => { table.on("datatable.perpage", () => itemListCheckChanged()); table.on("datatable.update", () => itemListCheckChanged()); } - + if (action == "softInit") { softInit(); itemListCheckChanged(); @@ -103,8 +103,8 @@ const selectorController = (action) => { function itemListCheckChanged() { alertInfoDevices.innerHTML = `Selected devices: ${TableController.getSelectedDevices().length} ${TableController.getAllDevices().length != TableController.getSelectedDevices().length - ? `Select all devices (${TableController.getAllDevices().length})` - : "Cancel selection" + ? `Select all devices (${TableController.getAllDevices().length})` + : "Cancel selection" }`; if (TableController.getSelectedDevices().length <= 0) { @@ -132,7 +132,7 @@ const selectorController = (action) => { get_device_list(); } - + btnSelectAll.addEventListener("click", event => { const checkedState = event.target.checked; TableController.getAllDevicesInCurrentPage().forEach(ckeckbox => { ckeckbox.checked = checkedState }); @@ -317,6 +317,47 @@ function export_file(type_file) { } } +class lotsSearcher { + static lots = []; + + static lotsSearchElement = null; + + static getListLots = () => { + const lotsList = document.getElementById("LotsSelector") + if (lotsList) { + // Apply filter to get only labels + return Array.from(lotsList.children).filter(item => item.querySelector("label")); + } + return []; + } + + static enable() { + if (this.lotsSearchElement) this.lotsSearchElement.disabled = false; + } + + static disable() { + if (this.lotsSearchElement) this.lotsSearchElement.disabled = true; + } + + /** + * do search when lot change in the search input + */ + static doSearch(inputSearch) { + const lots = this.getListLots(); + for (let i = 0; i < lots.length; i++) { + if (lot.innerText.toLowerCase().includes(inputSearch.toLowerCase())) { + lot.parentElement.style.display = ""; + } else { + lot.parentElement.style.display = "none"; + } + } + } +} + +document.addEventListener("DOMContentLoaded", () => { + lotsSearcher.lotsSearchElement = document.getElementById("lots-search"); + lotsSearcher.lotsSearchElement.addEventListener("input", (e) => { lotsSearcher.doSearch(e.target.value) }) +}) /** * Reactive lots button @@ -438,7 +479,7 @@ async function processSelectedDevices() { const tmpDiv = document.createElement("div") tmpDiv.innerHTML = newRequest - + const newTable = document.createElement("table") newTable.innerHTML = tmpDiv.querySelector("table").innerHTML newTable.classList = "table" @@ -557,6 +598,7 @@ async function processSelectedDevices() { document.getElementById("ApplyDeviceLots").classList.add("disabled"); try { + lotsSearcher.disable() listHTML.html("
  • ") const selectedDevices = await Api.get_devices(selectedDevicesID); let lots = await Api.get_lots(); @@ -589,6 +631,7 @@ async function processSelectedDevices() { listHTML.html(""); lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions)); + lotsSearcher.enable(); } catch (error) { console.log(error); listHTML.html("
  • Error feching devices and lots
    (see console for more details)
  • "); diff --git a/ereuse_devicehub/templates/inventory/device_list.html b/ereuse_devicehub/templates/inventory/device_list.html index 59050e5a..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 %}
    + {% 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 73b3d455..ffa0f3e9 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -61,6 +61,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/snapshots/{snapshot_uuid}/', '/inventory/snapshots/', diff --git a/tests/test_render_2_0.py b/tests/test_render_2_0.py index fc9b71ec..9805aa5c 100644 --- a/tests/test_render_2_0.py +++ b/tests/test_render_2_0.py @@ -1,4 +1,5 @@ import csv +import datetime import json from io import BytesIO from pathlib import Path @@ -686,6 +687,34 @@ def test_action_allocate_error_dates(user3: UserClientFlask): assert dev.actions[-1].type != 'Allocate' +@pytest.mark.mvp +@pytest.mark.usefixtures(conftest.app_context.__name__) +def test_action_allocate_error_future_dates(user3: UserClientFlask): + snap = create_device(user3, 'real-eee-1001pxd.snapshot.12.json') + dev = snap.device + uri = '/inventory/device/' + user3.get(uri) + start_time = (datetime.datetime.now() + datetime.timedelta(1)).strftime('%Y-%m-%d') + end_time = (datetime.datetime.now() + datetime.timedelta(10)).strftime('%Y-%m-%d') + + data = { + 'csrf_token': generate_csrf(), + 'type': "Allocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': start_time, + 'end_time': end_time, + 'end_users': 2, + } + + uri = '/inventory/action/allocate/add/' + body, status = user3.post(uri, data=data) + assert status == '200 OK' + assert 'Action Allocate error' in body + assert 'Not a valid date value.!' in body + assert dev.actions[-1].type != 'Allocate' + + @pytest.mark.mvp @pytest.mark.usefixtures(conftest.app_context.__name__) def test_action_deallocate(user3: UserClientFlask): @@ -707,7 +736,7 @@ def test_action_deallocate(user3: UserClientFlask): uri = '/inventory/action/allocate/add/' user3.post(uri, data=data) - assert dev.actions[-1].type == 'Allocate' + assert dev.allocated_status.type == 'Allocate' data = { 'csrf_token': generate_csrf(), @@ -720,11 +749,200 @@ def test_action_deallocate(user3: UserClientFlask): } body, status = user3.post(uri, data=data) assert status == '200 OK' - assert dev.actions[-1].type == 'Deallocate' + assert dev.allocated_status.type == 'Deallocate' assert 'Action "Deallocate" created successfully!' in body assert dev.devicehub_id in body +@pytest.mark.mvp +@pytest.mark.usefixtures(conftest.app_context.__name__) +def test_action_deallocate_error(user3: UserClientFlask): + snap = create_device(user3, 'real-eee-1001pxd.snapshot.12.json') + dev = snap.device + uri = '/inventory/device/' + user3.get(uri) + + data = { + 'csrf_token': generate_csrf(), + 'type': "Allocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-05-01', + 'end_time': '2000-06-01', + 'end_users': 2, + } + + uri = '/inventory/action/allocate/add/' + + user3.post(uri, data=data) + assert dev.allocated_status.type == 'Allocate' + + data = { + 'csrf_token': generate_csrf(), + 'type': "Deallocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-01', + 'end_time': '2000-02-01', + 'end_users': 2, + } + body, status = user3.post(uri, data=data) + assert status == '200 OK' + assert dev.allocated_status.type != 'Deallocate' + assert 'Action Deallocate error!' in body + assert 'Sorry some of this devices are actually deallocate' in body + + +@pytest.mark.mvp +@pytest.mark.usefixtures(conftest.app_context.__name__) +def test_action_allocate_deallocate_error(user3: UserClientFlask): + snap = create_device(user3, 'real-eee-1001pxd.snapshot.12.json') + dev = snap.device + uri = '/inventory/device/' + user3.get(uri) + + data = { + 'csrf_token': generate_csrf(), + 'type': "Allocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-01', + 'end_time': '2000-01-01', + 'end_users': 2, + } + + uri = '/inventory/action/allocate/add/' + + user3.post(uri, data=data) + assert dev.allocated_status.type == 'Allocate' + assert len(dev.actions) == 13 + + data = { + 'csrf_token': generate_csrf(), + 'type': "Deallocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-02-01', + 'end_time': '2000-02-01', + 'end_users': 2, + } + body, status = user3.post(uri, data=data) + assert status == '200 OK' + assert dev.allocated_status.type == 'Deallocate' + assert len(dev.actions) == 14 + + # is not possible to do an allocate between an allocate and an deallocate + data = { + 'csrf_token': generate_csrf(), + 'type': "Allocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-15', + 'end_time': '2000-01-15', + 'end_users': 2, + } + + user3.post(uri, data=data) + assert dev.allocated_status.type == 'Deallocate' + # assert 'Action Deallocate error!' in body + # assert 'Sorry some of this devices are actually deallocate' in body + # + data = { + 'csrf_token': generate_csrf(), + 'type': "Deallocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-15', + 'end_time': '2000-01-15', + 'end_users': 2, + } + + user3.post(uri, data=data) + assert len(dev.actions) == 14 + + +@pytest.mark.mvp +@pytest.mark.usefixtures(conftest.app_context.__name__) +def test_action_allocate_deallocate_error2(user3: UserClientFlask): + snap = create_device(user3, 'real-eee-1001pxd.snapshot.12.json') + dev = snap.device + uri = '/inventory/device/' + user3.get(uri) + + data = { + 'csrf_token': generate_csrf(), + 'type': "Allocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-10', + 'end_users': 2, + } + + uri = '/inventory/action/allocate/add/' + + user3.post(uri, data=data) + assert len(dev.actions) == 13 + + data = { + 'csrf_token': generate_csrf(), + 'type': "Deallocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-20', + 'end_users': 2, + } + body, status = user3.post(uri, data=data) + assert status == '200 OK' + assert len(dev.actions) == 14 + + data = { + 'csrf_token': generate_csrf(), + 'type': "Allocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-02-10', + 'end_users': 2, + } + + uri = '/inventory/action/allocate/add/' + + user3.post(uri, data=data) + assert len(dev.actions) == 15 + + data = { + 'csrf_token': generate_csrf(), + 'type': "Deallocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-02-20', + 'end_users': 2, + } + user3.post(uri, data=data) + assert len(dev.actions) == 16 + + data = { + 'csrf_token': generate_csrf(), + 'type': "Allocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-25', + 'end_users': 2, + } + user3.post(uri, data=data) + assert len(dev.actions) == 17 + + data = { + 'csrf_token': generate_csrf(), + 'type': "Deallocate", + 'severity': "Info", + 'devices': "{}".format(dev.id), + 'start_time': '2000-01-27', + 'end_users': 2, + } + user3.post(uri, data=data) + assert len(dev.actions) == 18 + + @pytest.mark.mvp @pytest.mark.usefixtures(conftest.app_context.__name__) def test_action_toprepare(user3: UserClientFlask): @@ -866,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