resolve conflicts

This commit is contained in:
Cayo Puigdefabregas 2022-06-01 15:01:02 +02:00
commit f58993c9d1
19 changed files with 1798 additions and 589 deletions

View file

@ -28,7 +28,7 @@
"class-methods-use-this": "off", "class-methods-use-this": "off",
"eqeqeq": "warn", "eqeqeq": "warn",
"radix": "warn", "radix": "warn",
"max-classes-per-file": ["error", 2] "max-classes-per-file": "warn"
}, },
"globals": { "globals": {
"API_URLS": true, "API_URLS": true,

View file

@ -8,6 +8,7 @@ ml).
## master ## master
## testing ## testing
- [added] #273 Allow search/filter lots on lots management component.
## [2.1.1] - 2022-05-11 ## [2.1.1] - 2022-05-11
Hot fix release. Hot fix release.

View file

@ -1,4 +1,5 @@
import copy import copy
import datetime
import json import json
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
@ -27,6 +28,7 @@ from wtforms import (
from wtforms.fields import FormField from wtforms.fields import FormField
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.inventory.models import Transfer
from ereuse_devicehub.parser.models import SnapshotsLog from ereuse_devicehub.parser.models import SnapshotsLog
from ereuse_devicehub.parser.parser import ParseSnapshotLsHw from ereuse_devicehub.parser.parser import ParseSnapshotLsHw
from ereuse_devicehub.parser.schemas import Snapshot_lite from ereuse_devicehub.parser.schemas import Snapshot_lite
@ -649,6 +651,10 @@ class AllocateForm(ActionFormMixin):
self.start_time.errors = ['Not a valid date value.!'] self.start_time.errors = ['Not a valid date value.!']
return False 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: if start_time and end_time and end_time < start_time:
error = ['The action cannot finish before it starts.'] error = ['The action cannot finish before it starts.']
self.end_time.errors = error self.end_time.errors = error
@ -661,23 +667,100 @@ class AllocateForm(ActionFormMixin):
def check_devices(self): def check_devices(self):
if self.type.data == 'Allocate': if self.type.data == 'Allocate':
return self.check_allocate()
if self.type.data == 'Deallocate':
return self.check_deallocate()
return True
def check_allocate(self):
txt = "You need deallocate before allocate this device again" txt = "You need deallocate before allocate this device again"
for device in self._devices: for device in self._devices:
if device.allocated: # | 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] self.devices.errors = [txt]
return False return False
device.allocated = True device.allocated = True
return True
if self.type.data == 'Deallocate': def check_deallocate(self):
txt = "Sorry some of this devices are actually deallocate" txt = "Sorry some of this devices are actually deallocate"
for device in self._devices: for device in self._devices:
if not device.allocated: 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] self.devices.errors = [txt]
return False return False
device.allocated = False device.allocated = False
return True return True
@ -998,3 +1081,85 @@ class TradeDocumentForm(FlaskForm):
db.session.commit() db.session.commit()
return self._obj 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)

View file

@ -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

View file

@ -15,6 +15,7 @@ from ereuse_devicehub.db import db
from ereuse_devicehub.inventory.forms import ( from ereuse_devicehub.inventory.forms import (
AllocateForm, AllocateForm,
DataWipeForm, DataWipeForm,
EditTransferForm,
FilterForm, FilterForm,
LotForm, LotForm,
NewActionForm, NewActionForm,
@ -22,6 +23,7 @@ from ereuse_devicehub.inventory.forms import (
TagDeviceForm, TagDeviceForm,
TradeDocumentForm, TradeDocumentForm,
TradeForm, TradeForm,
TransferForm,
UploadSnapshotForm, UploadSnapshotForm,
) )
from ereuse_devicehub.labels.forms import PrintLabelsForm from ereuse_devicehub.labels.forms import PrintLabelsForm
@ -48,16 +50,12 @@ class DeviceListMixin(GenericMixin):
form_filter = FilterForm(lots, lot_id, only_unassigned=only_unassigned) form_filter = FilterForm(lots, lot_id, only_unassigned=only_unassigned)
devices = form_filter.search() devices = form_filter.search()
lot = None lot = None
form_transfer = ''
if lot_id: if lot_id:
lot = lots.filter(Lot.id == lot_id).one() lot = lots.filter(Lot.id == lot_id).one()
form_new_trade = TradeForm( if not lot.is_temporary and lot.transfer:
lot=lot.id, form_transfer = EditTransferForm(lot_id=lot.id)
user_to=g.user.email,
user_from=g.user.email,
)
else:
form_new_trade = ''
form_new_action = NewActionForm(lot=lot_id) form_new_action = NewActionForm(lot=lot_id)
self.context.update( self.context.update(
@ -67,7 +65,7 @@ class DeviceListMixin(GenericMixin):
'form_new_action': form_new_action, 'form_new_action': form_new_action,
'form_new_allocate': AllocateForm(lot=lot_id), 'form_new_allocate': AllocateForm(lot=lot_id),
'form_new_datawipe': DataWipeForm(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_filter': form_filter,
'form_print_labels': PrintLabelsForm(), 'form_print_labels': PrintLabelsForm(),
'lot': lot, 'lot': lot,
@ -402,6 +400,48 @@ class NewTradeDocumentView(View):
return flask.render_template(self.template_name, **self.context) 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): class ExportsView(View):
methods = ['GET'] methods = ['GET']
decorators = [login_required] decorators = [login_required]
@ -628,3 +668,11 @@ devices.add_url_rule(
'/snapshots/<string:snapshot_uuid>/', '/snapshots/<string:snapshot_uuid>/',
view_func=SnapshotDetailView.as_view('snapshot_detail'), view_func=SnapshotDetailView.as_view('snapshot_detail'),
) )
devices.add_url_rule(
'/lot/<string:lot_id>/transfer/<string:type_id>/',
view_func=NewTransferView.as_view('new_transfer'),
)
devices.add_url_rule(
'/lot/<string:lot_id>/transfer/',
view_func=EditTransferView.as_view('edit_transfer'),
)

View file

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

View file

@ -5,14 +5,11 @@ Revises: eca457d8b2a4
Create Date: 2021-03-15 17:40:34.410408 Create Date: 2021-03-15 17:40:34.410408
""" """
import sqlalchemy as sa
import citext import citext
import teal import sqlalchemy as sa
from alembic import op from alembic import context, op
from alembic import context
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '51439cf24be8' revision = '51439cf24be8'
down_revision = '21afd375a654' down_revision = '21afd375a654'
@ -36,21 +33,126 @@ def upgrade_data():
def upgrade(): def upgrade():
## Trade # Trade
currency = sa.Enum('AFN', 'ARS', 'AWG', 'AUD', 'AZN', 'BSD', 'BBD', 'BDT', 'BYR', 'BZD', 'BMD', currency = sa.Enum(
'BOB', 'BAM', 'BWP', 'BGN', 'BRL', 'BND', 'KHR', 'CAD', 'KYD', 'CLP', 'CNY', 'AFN',
'COP', 'CRC', 'HRK', 'CUP', 'CZK', 'DKK', 'DOP', 'XCD', 'EGP', 'SVC', 'EEK', 'ARS',
'EUR', 'FKP', 'FJD', 'GHC', 'GIP', 'GTQ', 'GGP', 'GYD', 'HNL', 'HKD', 'HUF', 'AWG',
'ISK', 'INR', 'IDR', 'IRR', 'IMP', 'ILS', 'JMD', 'JPY', 'JEP', 'KZT', 'KPW', 'AUD',
'KRW', 'KGS', 'LAK', 'LVL', 'LBP', 'LRD', 'LTL', 'MKD', 'MYR', 'MUR', 'MXN', 'AZN',
'MNT', 'MZN', 'NAD', 'NPR', 'ANG', 'NZD', 'NIO', 'NGN', 'NOK', 'OMR', 'PKR', 'BSD',
'PAB', 'PYG', 'PEN', 'PHP', 'PLN', 'QAR', 'RON', 'RUB', 'SHP', 'SAR', 'RSD', 'BBD',
'SCR', 'SGD', 'SBD', 'SOS', 'ZAR', 'LKR', 'SEK', 'CHF', 'SRD', 'SYP', 'TWD', 'BDT',
'THB', 'TTD', 'TRY', 'TRL', 'TVD', 'UAH', 'GBP', 'USD', 'UYU', 'UZS', 'VEF', name='currency', create_type=False, checkfirst=True, schema=f'{get_inv()}') '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.drop_table('trade', schema=f'{get_inv()}')
op.create_table('trade', op.create_table(
'trade',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('price', sa.Float(decimal_return_scale=4), nullable=True), sa.Column('price', sa.Float(decimal_return_scale=4), nullable=True),
sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=True), sa.Column('lot_id', postgresql.UUID(as_uuid=True), nullable=True),
@ -59,36 +161,69 @@ def upgrade():
sa.Column('user_to_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('document_id', citext.CIText(), nullable=True),
sa.Column('confirm', sa.Boolean(), nullable=True), sa.Column('confirm', sa.Boolean(), nullable=True),
sa.Column('code', citext.CIText(), default='', nullable=True, sa.Column(
comment = "This code is used for traceability"), 'code',
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), citext.CIText(),
sa.ForeignKeyConstraint(['user_from_id'], ['common.user.id'], ), default='',
sa.ForeignKeyConstraint(['user_to_id'], ['common.user.id'], ), nullable=True,
sa.ForeignKeyConstraint(['lot_id'], [f'{get_inv()}.lot.id'], ), 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'), sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}' 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(
op.create_table('confirm', 'confirm',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_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.Column('action_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), ['id'],
sa.ForeignKeyConstraint(['action_id'], [f'{get_inv()}.action.id'], ), [f'{get_inv()}.action.id'],
sa.ForeignKeyConstraint(['user_id'], ['common.user.id'], ), ),
sa.ForeignKeyConstraint(
['action_id'],
[f'{get_inv()}.action.id'],
),
sa.ForeignKeyConstraint(
['user_id'],
['common.user.id'],
),
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}' schema=f'{get_inv()}',
) )
## User # User
op.add_column('user', sa.Column('active', sa.Boolean(), default=True, nullable=True), op.add_column(
schema='common') 'user',
op.add_column('user', sa.Column('phantom', sa.Boolean(), default=False, nullable=True), sa.Column('active', sa.Boolean(), default=True, nullable=True),
schema='common') schema='common',
)
op.add_column(
'user',
sa.Column('phantom', sa.Boolean(), default=False, nullable=True),
schema='common',
)
upgrade_data() upgrade_data()
@ -99,28 +234,57 @@ def upgrade():
def downgrade(): def downgrade():
op.drop_table('confirm', schema=f'{get_inv()}') op.drop_table('confirm', schema=f'{get_inv()}')
op.drop_table('trade', schema=f'{get_inv()}') op.drop_table('trade', schema=f'{get_inv()}')
op.create_table('trade', op.create_table(
sa.Column('shipping_date', sa.TIMESTAMP(timezone=True), nullable=True, 'trade',
sa.Column(
'shipping_date',
sa.TIMESTAMP(timezone=True),
nullable=True,
comment='When are the devices going to be ready \n \ comment='When are the devices going to be ready \n \
for shipping?\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(
sa.Column('price_id', postgresql.UUID(as_uuid=True), nullable=True, '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 \ comment='The price set for this trade. \n \
If no price is set it is supposed that the trade was\n \ If no price is set it is supposed that the trade was\n \
not payed, usual in donations.\n '), not payed, usual in donations.\n ',
),
sa.Column('to_id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('to_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('confirms_id', postgresql.UUID(as_uuid=True), nullable=True, sa.Column(
'confirms_id',
postgresql.UUID(as_uuid=True),
nullable=True,
comment='An organize action that this association confirms. \ comment='An organize action that this association confirms. \
\n \n For example, a ``Sell`` or ``Rent``\n \ \n \n For example, a ``Sell`` or ``Rent``\n \
can confirm a ``Reserve`` action.\n '), can confirm a ``Reserve`` action.\n ',
),
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['confirms_id'], [f'{get_inv()}.organize.id'], ), sa.ForeignKeyConstraint(
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), ['confirms_id'],
sa.ForeignKeyConstraint(['price_id'], [f'{get_inv()}.price.id'], ), [f'{get_inv()}.organize.id'],
sa.ForeignKeyConstraint(['to_id'], [f'{get_inv()}.agent.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'), sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}' schema=f'{get_inv()}',
) )
op.drop_column('user', 'active', schema='common') op.drop_column('user', 'active', schema='common')
op.drop_column('user', 'phantom', schema='common') op.drop_column('user', 'phantom', schema='common')

View file

@ -5,12 +5,10 @@ Revises: 3eb50297c365
Create Date: 2020-12-29 20:19:46.981207 Create Date: 2020-12-29 20:19:46.981207
""" """
from alembic import context
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import teal import teal
from alembic import context, op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = 'b4bd1538bad5' revision = 'b4bd1538bad5'
@ -25,33 +23,71 @@ def get_inv():
raise ValueError("Inventory value is not specified") raise ValueError("Inventory value is not specified")
return INV return INV
def upgrade(): 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 # Live action
op.drop_table('live', schema=f'{get_inv()}') 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('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('serial_number', sa.Unicode(), nullable=True, sa.Column(
comment='The serial number of the Hard Disk in lower case.'), '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('usage_time_hdd', sa.Interval(), nullable=True),
sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False), sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('software_version', teal.db.StrictVersionType(length=32), nullable=False), sa.Column(
sa.Column('licence_version', teal.db.StrictVersionType(length=32), nullable=False), 'software_version', teal.db.StrictVersionType(length=32), nullable=False
sa.Column('software', sa.Enum('Workbench', 'WorkbenchAndroid', 'AndroidApp', 'Web', ),
'DesktopApp', 'WorkbenchDesktop', name='snapshotsoftware'), nullable=False), sa.Column(
sa.ForeignKeyConstraint(['id'], [f'{get_inv()}.action.id'], ), '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'), sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}' schema=f'{get_inv()}',
) )
def downgrade(): def downgrade():
op.drop_table('live', schema=f'{get_inv()}') 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('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('serial_number', sa.Unicode(), nullable=True, sa.Column(
comment='The serial number of the Hard Disk in lower case.'), '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('usage_time_hdd', sa.Interval(), nullable=True),
sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False), 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'), 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'"
) )

View file

@ -1,16 +1,21 @@
from flask import g from flask import g
from sqlalchemy.util import OrderedSet
from teal.marshmallow import ValidationError from teal.marshmallow import ValidationError
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.resources.action.models import (Trade, Confirm, from ereuse_devicehub.inventory.models import Transfer
Revoke, RevokeDocument, ConfirmDocument, from ereuse_devicehub.resources.action.models import (
ConfirmRevokeDocument) Confirm,
from ereuse_devicehub.resources.user.models import User ConfirmDocument,
ConfirmRevokeDocument,
Revoke,
RevokeDocument,
Trade,
)
from ereuse_devicehub.resources.lot.views import delete_from_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 """Handler for manager the trade action register from post
request_post = { request_post = {
@ -37,6 +42,7 @@ class TradeView():
db.session.add(self.trade) db.session.add(self.trade)
self.create_confirmations() self.create_confirmations()
self.create_automatic_trade() self.create_automatic_trade()
self.create_transfer()
def post(self): def post(self):
db.session().final_flush() db.session().final_flush()
@ -52,15 +58,15 @@ class TradeView():
# owner of the lot # owner of the lot
if self.trade.confirm: if self.trade.confirm:
if self.trade.devices: if self.trade.devices:
confirm_devs = Confirm(user=g.user, confirm_devs = Confirm(
action=self.trade, user=g.user, action=self.trade, devices=self.trade.devices
devices=self.trade.devices) )
db.session.add(confirm_devs) db.session.add(confirm_devs)
if self.trade.documents: if self.trade.documents:
confirm_docs = ConfirmDocument(user=g.user, confirm_docs = ConfirmDocument(
action=self.trade, user=g.user, action=self.trade, documents=self.trade.documents
documents=self.trade.documents) )
db.session.add(confirm_docs) db.session.add(confirm_docs)
return return
@ -70,12 +76,12 @@ class TradeView():
txt = "You do not participate in this trading" txt = "You do not participate in this trading"
raise ValidationError(txt) raise ValidationError(txt)
confirm_from = Confirm(user=self.trade.user_from, confirm_from = Confirm(
action=self.trade, user=self.trade.user_from, action=self.trade, devices=self.trade.devices
devices=self.trade.devices) )
confirm_to = Confirm(user=self.trade.user_to, confirm_to = Confirm(
action=self.trade, user=self.trade.user_to, action=self.trade, devices=self.trade.devices
devices=self.trade.devices) )
db.session.add(confirm_from) db.session.add(confirm_from)
db.session.add(confirm_to) db.session.add(confirm_to)
@ -124,6 +130,25 @@ class TradeView():
db.session.add(user) db.session.add(user)
self.data['user_from'] = 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: def create_automatic_trade(self) -> None:
# not do nothing if it's neccesary confirmation explicity # not do nothing if it's neccesary confirmation explicity
if self.trade.confirm: if self.trade.confirm:
@ -134,7 +159,7 @@ class TradeView():
dev.change_owner(self.trade.user_to) dev.change_owner(self.trade.user_to)
class ConfirmMixin(): class ConfirmMixin:
""" """
Very Important: Very Important:
============== ==============
@ -184,7 +209,10 @@ class ConfirmView(ConfirmMixin):
trade = data['action'] trade = data['action']
lot = trade.lot lot = trade.lot
for dev in data['devices']: 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.') raise ValidationError('Some devices not possible confirm.')
# Change the owner for every devices # Change the owner for every devices
@ -223,7 +251,7 @@ class RevokeView(ConfirmMixin):
self.model = delete_from_trade(lot, devices) self.model = delete_from_trade(lot, devices)
class ConfirmDocumentMixin(): class ConfirmDocumentMixin:
""" """
Very Important: Very Important:
============== ==============
@ -299,7 +327,9 @@ class RevokeDocumentView(ConfirmDocumentMixin):
for doc in data['documents']: for doc in data['documents']:
if not doc.trading in ['Document Confirmed', 'Confirm']: 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) ValidationError(txt)
### End check ### ### End check ###

View file

@ -1,6 +1,5 @@
import copy import copy
import pathlib import pathlib
import time
from contextlib import suppress from contextlib import suppress
from fractions import Fraction from fractions import Fraction
from itertools import chain from itertools import chain
@ -406,7 +405,7 @@ class Device(Thing):
def tradings(self): def tradings(self):
return {str(x.id): self.trading(x.lot) for x in self.actions if x.t == 'Trade'} 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 """The trading state, or None if no Trade action has
ever been performed to this device. This extract the posibilities for to do. ever been performed to this device. This extract the posibilities for to do.
This method is performed for show in the web. This method is performed for show in the web.

View file

@ -5,7 +5,6 @@ from typing import Union
from boltons import urlutils from boltons import urlutils
from citext import CIText from citext import CIText
from flask import g from flask import g
from flask_login import current_user
from sqlalchemy import TEXT from sqlalchemy import TEXT
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy_utils import LtreeType from sqlalchemy_utils import LtreeType
@ -120,18 +119,24 @@ class Lot(Thing):
@property @property
def is_temporary(self): def is_temporary(self):
return not bool(self.trade) return not bool(self.trade) and not bool(self.transfer)
@property @property
def is_incoming(self): def is_incoming(self):
if hasattr(self, 'trade'): if self.trade:
return self.trade.user_to == g.user return self.trade.user_to == g.user
if self.transfer:
return self.transfer.user_to == g.user
return False return False
@property @property
def is_outgoing(self): def is_outgoing(self):
if hasattr(self, 'trade'): if self.trade:
return self.trade.user_to == g.user return self.trade.user_from == g.user
if self.transfer:
return self.transfer.user_from == g.user
return False return False
@classmethod @classmethod

View file

@ -1,20 +1,22 @@
import uuid import uuid
from sqlalchemy.util import OrderedSet
from collections import deque from collections import deque
from enum import Enum from enum import Enum
from typing import Dict, List, Set, Union from typing import Dict, List, Set, Union
import marshmallow as ma import marshmallow as ma
from flask import Response, jsonify, request, g from flask import Response, g, jsonify, request
from marshmallow import Schema as MarshmallowSchema, fields as f from marshmallow import Schema as MarshmallowSchema
from marshmallow import fields as f
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.util import OrderedSet
from teal.marshmallow import EnumField from teal.marshmallow import EnumField
from teal.resource import View from teal.resource import View
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.inventory.models import Transfer
from ereuse_devicehub.query import things_response from ereuse_devicehub.query import things_response
from ereuse_devicehub.resources.device.models import Device, Computer from ereuse_devicehub.resources.action.models import Confirm, Revoke, Trade
from ereuse_devicehub.resources.action.models import Trade, Confirm, Revoke from ereuse_devicehub.resources.device.models import Computer, Device
from ereuse_devicehub.resources.lot.models import Lot, Path from ereuse_devicehub.resources.lot.models import Lot, Path
@ -27,6 +29,7 @@ class LotView(View):
"""Allowed arguments for the ``find`` """Allowed arguments for the ``find``
method (GET collection) endpoint method (GET collection) endpoint
""" """
format = EnumField(LotFormat, missing=None) format = EnumField(LotFormat, missing=None)
search = f.Str(missing=None) search = f.Str(missing=None)
type = f.Str(missing=None) type = f.Str(missing=None)
@ -42,12 +45,26 @@ class LotView(View):
return ret return ret
def patch(self, id): def patch(self, id):
patch_schema = self.resource_def.SCHEMA(only=( patch_schema = self.resource_def.SCHEMA(
'name', 'description', 'transfer_state', 'receiver_address', 'amount', 'devices', only=(
'owner_address'), partial=True) 'name',
'description',
'transfer_state',
'receiver_address',
'amount',
'devices',
'owner_address',
),
partial=True,
)
l = request.get_json(schema=patch_schema) l = request.get_json(schema=patch_schema)
lot = Lot.query.filter_by(id=id).one() 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)] computers = [x for x in lot.all_devices if isinstance(x, Computer)]
for key, value in l.items(): for key, value in l.items():
setattr(lot, key, value) setattr(lot, key, value)
@ -84,7 +101,7 @@ class LotView(View):
ret = { ret = {
'items': {l['id']: l for l in lots}, 'items': {l['id']: l for l in lots},
'tree': self.ui_tree(), 'tree': self.ui_tree(),
'url': request.path 'url': request.path,
} }
else: else:
query = Lot.query query = Lot.query
@ -95,15 +112,28 @@ class LotView(View):
lots = query.paginate(per_page=6 if args['search'] else query.count()) lots = query.paginate(per_page=6 if args['search'] else query.count())
return things_response( return things_response(
self.schema.dump(lots.items, many=True, nested=2), 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) return jsonify(ret)
def visibility_filter(self, query): def visibility_filter(self, query):
query = query.outerjoin(Trade) \ query = (
.filter(or_(Trade.user_from == g.user, query.outerjoin(Trade)
.outerjoin(Transfer)
.filter(
or_(
Trade.user_from == g.user,
Trade.user_to == g.user, Trade.user_to == g.user,
Lot.owner_id == g.user.id)) Lot.owner_id == g.user.id,
Transfer.user_from == g.user,
Transfer.user_to == g.user,
)
)
)
return query return query
def type_filter(self, query, args): def type_filter(self, query, args):
@ -111,13 +141,23 @@ class LotView(View):
# temporary # temporary
if lot_type == "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": 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": 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 return query
@ -152,10 +192,7 @@ class LotView(View):
# does lot_id exist already in node? # does lot_id exist already in node?
node = next(part for part in nodes if lot_id == part['id']) node = next(part for part in nodes if lot_id == part['id'])
except StopIteration: except StopIteration:
node = { node = {'id': lot_id, 'nodes': []}
'id': lot_id,
'nodes': []
}
nodes.append(node) nodes.append(node)
if path: if path:
cls._p(node['nodes'], path) cls._p(node['nodes'], path)
@ -183,7 +220,9 @@ class LotBaseChildrenView(View):
self.list_args = self.ListArgs() self.list_args = self.ListArgs()
def get_ids(self) -> Set[uuid.UUID]: 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']) return set(args['id'])
def get_lot(self, id: uuid.UUID) -> Lot: def get_lot(self, id: uuid.UUID) -> Lot:
@ -247,8 +286,9 @@ class LotDeviceView(LotBaseChildrenView):
if not ids: if not ids:
return return
devices = set(Device.query.filter(Device.id.in_(ids)).filter( devices = set(
Device.owner == g.user)) Device.query.filter(Device.id.in_(ids)).filter(Device.owner == g.user)
)
lot.devices.update(devices) lot.devices.update(devices)
@ -271,8 +311,9 @@ class LotDeviceView(LotBaseChildrenView):
txt = 'This is not your lot' txt = 'This is not your lot'
raise ma.ValidationError(txt) raise ma.ValidationError(txt)
devices = set(Device.query.filter(Device.id.in_(ids)).filter( devices = set(
Device.owner_id == g.user.id)) Device.query.filter(Device.id.in_(ids)).filter(Device.owner_id == g.user.id)
)
lot.devices.difference_update(devices) lot.devices.difference_update(devices)
@ -311,9 +352,7 @@ def delete_from_trade(lot: Lot, devices: List):
phantom = lot.trade.user_from phantom = lot.trade.user_from
phantom_revoke = Revoke( phantom_revoke = Revoke(
action=lot.trade, action=lot.trade, user=phantom, devices=set(without_confirms)
user=phantom,
devices=set(without_confirms)
) )
db.session.add(phantom_revoke) db.session.add(phantom_revoke)

View file

@ -218,9 +218,13 @@
/** /**
* Avoid hide dropdown when user clicked inside * Avoid hide dropdown when user clicked inside
*/ */
document.getElementById("dropDownLotsSelector").addEventListener("click", event => { const dropdownLotSelector = document.getElementById("dropDownLotsSelector")
if (dropdownLotSelector != null) { // If exists selector it will set click event
dropdownLotSelector.addEventListener("click", event => {
event.stopPropagation(); event.stopPropagation();
}) })
}
/** /**
* Search form functionality * Search form functionality

View file

@ -1,5 +1,7 @@
"use strict"; "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 _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"); } } 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(); $("#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 * Reactive lots button
*/ */
async function processSelectedDevices() { async function processSelectedDevices() {
class Actions { class Actions {
constructor() { constructor() {
@ -584,6 +634,7 @@ async function processSelectedDevices() {
document.getElementById("ApplyDeviceLots").classList.add("disabled"); document.getElementById("ApplyDeviceLots").classList.add("disabled");
try { try {
lotsSearcher.disable();
listHTML.html("<li style=\"text-align: center\"><div class=\"spinner-border text-info\" style=\"margin: auto\" role=\"status\"></div></li>"); listHTML.html("<li style=\"text-align: center\"><div class=\"spinner-border text-info\" style=\"margin: auto\" role=\"status\"></div></li>");
const selectedDevices = await Api.get_devices(selectedDevicesID); const selectedDevices = await Api.get_devices(selectedDevicesID);
let lots = await Api.get_lots(); let lots = await Api.get_lots();
@ -614,6 +665,7 @@ async function processSelectedDevices() {
listHTML.html(""); listHTML.html("");
lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions)); lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions));
lotsSearcher.enable();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
listHTML.html("<li style=\"color: red; text-align: center\">Error feching devices and lots<br>(see console for more details)</li>"); listHTML.html("<li style=\"color: red; text-align: center\">Error feching devices and lots<br>(see console for more details)</li>");

View file

@ -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 * Reactive lots button
@ -557,6 +598,7 @@ async function processSelectedDevices() {
document.getElementById("ApplyDeviceLots").classList.add("disabled"); document.getElementById("ApplyDeviceLots").classList.add("disabled");
try { try {
lotsSearcher.disable()
listHTML.html("<li style=\"text-align: center\"><div class=\"spinner-border text-info\" style=\"margin: auto\" role=\"status\"></div></li>") listHTML.html("<li style=\"text-align: center\"><div class=\"spinner-border text-info\" style=\"margin: auto\" role=\"status\"></div></li>")
const selectedDevices = await Api.get_devices(selectedDevicesID); const selectedDevices = await Api.get_devices(selectedDevicesID);
let lots = await Api.get_lots(); let lots = await Api.get_lots();
@ -589,6 +631,7 @@ async function processSelectedDevices() {
listHTML.html(""); listHTML.html("");
lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions)); lotsList.forEach(lot => templateLot(lot, selectedDevices, listHTML, actions));
lotsSearcher.enable();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
listHTML.html("<li style=\"color: red; text-align: center\">Error feching devices and lots<br>(see console for more details)</li>"); listHTML.html("<li style=\"color: red; text-align: center\">Error feching devices and lots<br>(see console for more details)</li>");

View file

@ -37,10 +37,19 @@
<!-- Bordered Tabs --> <!-- Bordered Tabs -->
<div class="d-flex align-items-center justify-content-between row"> <div class="d-flex align-items-center justify-content-between row">
<h3 class="col-sm-12 col-md-5"><a href="{{ url_for('inventory.lot_edit', id=lot.id) }}">{{ lot.name }}</a></h3> <div class="col-sm-12 col-md-5">
<h3>
<a href="{{ url_for('inventory.lot_edit', id=lot.id) }}">{{ lot.name }}</a>
</h3>
{% if lot.transfer.code and lot.transfer.user_to and not lot.transfer.user_to.phantom %}
<span>{{ lot.transfer.code }} <i class="bi bi-arrow-right"></i> {{ lot.transfer.user_to.email }}</span>
{% elif lot.transfer.code and lot.transfer.user_from and not lot.transfer.user_from.phantom %}
<span>{{ lot.transfer.user_from.email }} <i class="bi bi-arrow-right"></i> {{ lot.transfer.code }}</span>
{% endif %}
</div>
<div class="col-sm-12 col-md-7 d-md-flex justify-content-md-end"><!-- lot actions --> <div class="col-sm-12 col-md-7 d-md-flex justify-content-md-end"><!-- lot actions -->
{% if lot.is_temporary %} {% if lot.is_temporary or not lot.transfer.closed %}
{% if 1 == 2 %}{# <!-- TODO (@slamora) Don't render Trade buttons until implementation is finished --> #} {% if 1 == 2 %}{# <!-- TODO (@slamora) Don't render Trade buttons until implementation is finished --> #}
<a class="me-2" href="javascript:newTrade('user_from')"> <a class="me-2" href="javascript:newTrade('user_from')">
@ -75,9 +84,28 @@
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#trade-documents-list">Documents</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#trade-documents-list">Documents</button>
</li> </li>
{% if lot.transfer %}
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#edit-transfer">
Transfer ({% if lot.transfer.closed %}<span class="text-danger">Closed</span>{% else %}<span class="text-success">Open</span>{% endif %})
</button>
</li>
{% endif %}
</ul> </ul>
{% endif %} {% endif %}
<div class="tab-content pt-1"> <div class="tab-content pt-1">
{% if lot and lot.is_temporary %}
<div class="tab-pane active show mb-5">
<a type="button" href="{{ url_for('inventory.new_transfer', lot_id=lot.id, type_id='outgoing') }}" class="btn btn-primary" style="float: right;">
Outgoing Transfer
</a>
<a type="button" href="{{ url_for('inventory.new_transfer', lot_id=lot.id, type_id='incoming') }}" class="btn btn-primary" style="float: right; margin-right: 15px;">
Incoming Transfer
</a>
<div style="display: block;"></div>
</div>
{% endif %}
<div id="devices-list" class="tab-pane fade devices-list active show"> <div id="devices-list" class="tab-pane fade devices-list active show">
<label class="btn btn-primary " for="SelectAllBTN"><input type="checkbox" id="SelectAllBTN" autocomplete="off"></label> <label class="btn btn-primary " for="SelectAllBTN"><input type="checkbox" id="SelectAllBTN" autocomplete="off"></label>
<div class="btn-group dropdown ml-1"> <div class="btn-group dropdown ml-1">
@ -87,7 +115,16 @@
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<span class="d-none" id="activeTradeModal" data-bs-toggle="modal" data-bs-target="#tradeLotModal"></span> <span class="d-none" id="activeTradeModal" data-bs-toggle="modal" data-bs-target="#tradeLotModal"></span>
<ul class="dropdown-menu" aria-labelledby="btnLots" id="dropDownLotsSelector"> <ul class="dropdown-menu" aria-labelledby="btnLots" id="dropDownLotsSelector">
<div class="row w-100">
<div class="input-group mb-3 mx-2">
<div class="input-group-prepend">
<span class="input-group-text" id="basic-addon1"><i class="bi bi-search"></i></span>
</div>
<input type="text" class="form-control" id="lots-search" placeholder="search" aria-label="search" aria-describedby="basic-addon1">
</div>
</div>
<h6 class="dropdown-header">Select lots where to store the selected devices</h6> <h6 class="dropdown-header">Select lots where to store the selected devices</h6>
<ul class="mx-3" id="LotsSelector"></ul> <ul class="mx-3" id="LotsSelector"></ul>
<li><hr /></li> <li><hr /></li>
@ -341,7 +378,8 @@
<th scope="col">Lifecycle Status</th> <th scope="col">Lifecycle Status</th>
<th scope="col">Allocated Status</th> <th scope="col">Allocated Status</th>
<th scope="col">Physical Status</th> <th scope="col">Physical Status</th>
<th scope="col" data-type="date" data-format="DD-MM-YYYY">Update</th> <th scope="col" data-type="date" data-format="YYYY-MM-DD">Updated in</th>
<th scope="col" data-type="date" data-format="YYYY-MM-DD hh:mm:ss">Registered in</th>
<th scope="col"></th> <th scope="col"></th>
</tr> </tr>
</thead> </thead>
@ -386,7 +424,8 @@
<td>{% if dev.status %}{{ dev.status.type }}{% endif %}</td> <td>{% if dev.status %}{{ dev.status.type }}{% endif %}</td>
<td>{% if dev.allocated_status %}{{ dev.allocated_status.type }}{% endif %}</td> <td>{% if dev.allocated_status %}{{ dev.allocated_status.type }}{% endif %}</td>
<td>{% if dev.physical_status %}{{ dev.physical_status.type }}{% endif %}</td> <td>{% if dev.physical_status %}{{ dev.physical_status.type }}{% endif %}</td>
<td>{{ dev.updated.strftime('%H:%M %d-%m-%Y') }}</td> <td>{{ dev.updated.strftime('%Y-%m-%d %H:%M:%S')}}</td>
<td>{{ dev.created.strftime('%Y-%m-%d %H:%M:%S')}}</td>
<td> <td>
<a href="{{ dev.public_link }}" target="_blank"> <a href="{{ dev.public_link }}" target="_blank">
<i class="bi bi-box-arrow-up-right"></i> <i class="bi bi-box-arrow-up-right"></i>
@ -427,6 +466,38 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="edit-transfer" class="tab-pane fade edit-transfer">
<h5 class="card-title">Transfer</h5>
<form method="post" action="{{ url_for('inventory.edit_transfer', lot_id=lot.id) }}" class="row g-3 needs-validation" novalidate>
{{ form_transfer.csrf_token }}
{% for field in form_transfer %}
{% if field != form_transfer.csrf_token %}
<div class="col-12">
{% if field != form_transfer.type %}
{{ field.label(class_="form-label") }}
{% if field == form_transfer.code %}
<span class="text-danger">*</span>
{% endif %}
{{ field }}
{% if field.errors %}
<p class="text-danger">
{% for error in field.errors %}
{{ error }}<br/>
{% endfor %}
</p>
{% endif %}
{% endif %}
</div>
{% endif %}
{% endfor %}
<div>
<a href="{{ url_for('inventory.lotdevicelist', lot_id=lot.id) }}" class="btn btn-danger">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
{% endif %} {% endif %}
</div><!-- End Bordered Tabs --> </div><!-- End Bordered Tabs -->

View file

@ -0,0 +1,73 @@
{% extends "ereuse_devicehub/base_site.html" %}
{% block main %}
<div class="pagetitle">
<h1>{{ title }}</h1>
<nav>
<ol class="breadcrumb">
<!-- TODO@slamora replace with lot list URL when exists -->
<li class="breadcrumb-item"><a href="#TODO-lot-list">Lots</a></li>
<li class="breadcrumb-item">Transfer</li>
</ol>
</nav>
</div><!-- End Page Title -->
<section class="section profile">
<div class="row">
<div class="col-xl-4">
<div class="card">
<div class="card-body">
<div class="pt-4 pb-2">
<h5 class="card-title text-center pb-0 fs-4">{{ title }}</h5>
{% if form.form_errors %}
<p class="text-danger">
{% for error in form.form_errors %}
{{ error }}<br/>
{% endfor %}
</p>
{% endif %}
</div>
<form method="post" class="row g-3 needs-validation" novalidate>
{{ form.csrf_token }}
{% for field in form %}
{% if field != form.csrf_token %}
<div class="col-12">
{% if field != form.type %}
{{ field.label(class_="form-label") }}
{% if field == form.code %}
<span class="text-danger">*</span>
{% endif %}
{{ field }}
{% if field.errors %}
<p class="text-danger">
{% for error in field.errors %}
{{ error }}<br/>
{% endfor %}
</p>
{% endif %}
{% endif %}
</div>
{% endif %}
{% endfor %}
<div>
<a href="{{ url_for('inventory.lotdevicelist', lot_id=form._tmp_lot.id) }}" class="btn btn-danger">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-xl-8">
</div>
</div>
</section>
{% endblock main %}

View file

@ -61,6 +61,8 @@ def test_api_docs(client: Client):
'/inventory/lot/{lot_id}/device/', '/inventory/lot/{lot_id}/device/',
'/inventory/lot/{lot_id}/device/add/', '/inventory/lot/{lot_id}/device/add/',
'/inventory/lot/{lot_id}/trade-document/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/lot/{lot_id}/upload-snapshot/',
'/inventory/snapshots/{snapshot_uuid}/', '/inventory/snapshots/{snapshot_uuid}/',
'/inventory/snapshots/', '/inventory/snapshots/',

View file

@ -1,4 +1,5 @@
import csv import csv
import datetime
import json import json
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@ -686,6 +687,34 @@ def test_action_allocate_error_dates(user3: UserClientFlask):
assert dev.actions[-1].type != 'Allocate' 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.mvp
@pytest.mark.usefixtures(conftest.app_context.__name__) @pytest.mark.usefixtures(conftest.app_context.__name__)
def test_action_deallocate(user3: UserClientFlask): def test_action_deallocate(user3: UserClientFlask):
@ -707,7 +736,7 @@ def test_action_deallocate(user3: UserClientFlask):
uri = '/inventory/action/allocate/add/' uri = '/inventory/action/allocate/add/'
user3.post(uri, data=data) user3.post(uri, data=data)
assert dev.actions[-1].type == 'Allocate' assert dev.allocated_status.type == 'Allocate'
data = { data = {
'csrf_token': generate_csrf(), 'csrf_token': generate_csrf(),
@ -720,11 +749,200 @@ def test_action_deallocate(user3: UserClientFlask):
} }
body, status = user3.post(uri, data=data) body, status = user3.post(uri, data=data)
assert status == '200 OK' assert status == '200 OK'
assert dev.actions[-1].type == 'Deallocate' assert dev.allocated_status.type == 'Deallocate'
assert 'Action &#34;Deallocate&#34; created successfully!' in body assert 'Action &#34;Deallocate&#34; created successfully!' in body
assert dev.devicehub_id 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.mvp
@pytest.mark.usefixtures(conftest.app_context.__name__) @pytest.mark.usefixtures(conftest.app_context.__name__)
def test_action_toprepare(user3: UserClientFlask): def test_action_toprepare(user3: UserClientFlask):
@ -866,3 +1084,94 @@ def test_wb_settings_register(user3: UserClientFlask):
assert "TOKEN = " in body assert "TOKEN = " in body
assert "URL = https://" in body assert "URL = https://" in body
assert "/api/inventory/" 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 (<span class="text-success">Open</span>)' not in body
assert '<i class="bi bi-trash"></i> 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 (<span class="text-success">Open</span>)' in body
assert '<i class="bi bi-trash"></i> 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 '<i class="bi bi-trash"></i> Delete Lot' in body
assert 'Transfer (<span class="text-success">Open</span>)' 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 '<i class="bi bi-trash"></i> Delete Lot' not in body
assert 'Transfer (<span class="text-danger">Closed</span>)' in body