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",
"eqeqeq": "warn",
"radix": "warn",
"max-classes-per-file": ["error", 2]
"max-classes-per-file": "warn"
},
"globals": {
"API_URLS": true,

View File

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

View File

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

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 (
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/<string:snapshot_uuid>/',
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
"""
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')

View File

@ -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'"
)

View File

@ -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],
}
"""

View File

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

View File

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

View File

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

View File

@ -218,9 +218,13 @@
/**
* Avoid hide dropdown when user clicked inside
*/
document.getElementById("dropDownLotsSelector").addEventListener("click", event => {
event.stopPropagation();
})
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

View File

@ -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("<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);
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("<li style=\"color: red; text-align: center\">Error feching devices and lots<br>(see console for more details)</li>");

View File

@ -103,8 +103,8 @@ const selectorController = (action) => {
function itemListCheckChanged() {
alertInfoDevices.innerHTML = `Selected devices: ${TableController.getSelectedDevices().length}
${TableController.getAllDevices().length != TableController.getSelectedDevices().length
? `<a href="#" class="ml-3">Select all devices (${TableController.getAllDevices().length})</a>`
: "<a href=\"#\" class=\"ml-3\">Cancel selection</a>"
? `<a href="#" class="ml-3">Select all devices (${TableController.getAllDevices().length})</a>`
: "<a href=\"#\" class=\"ml-3\">Cancel selection</a>"
}`;
if (TableController.getSelectedDevices().length <= 0) {
@ -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
@ -557,6 +598,7 @@ async function processSelectedDevices() {
document.getElementById("ApplyDeviceLots").classList.add("disabled");
try {
lotsSearcher.disable()
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);
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("<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 -->
<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 -->
{% 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 --> #}
<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>
</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>
{% endif %}
<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">
<label class="btn btn-primary " for="SelectAllBTN"><input type="checkbox" id="SelectAllBTN" autocomplete="off"></label>
<div class="btn-group dropdown ml-1">
@ -87,7 +115,16 @@
<span class="caret"></span>
</button>
<span class="d-none" id="activeTradeModal" data-bs-toggle="modal" data-bs-target="#tradeLotModal"></span>
<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>
<ul class="mx-3" id="LotsSelector"></ul>
<li><hr /></li>
@ -341,7 +378,8 @@
<th scope="col">Lifecycle Status</th>
<th scope="col">Allocated 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>
</tr>
</thead>
@ -386,7 +424,8 @@
<td>{% if dev.status %}{{ dev.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>{{ 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>
<a href="{{ dev.public_link }}" target="_blank">
<i class="bi bi-box-arrow-up-right"></i>
@ -427,6 +466,38 @@
</tbody>
</table>
</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 %}
</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/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/',

View File

@ -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 &#34;Deallocate&#34; 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 (<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