Merge pull request #289 from eReuse/feature/3415-transfer

Feature/3415 transfer
This commit is contained in:
cayop 2022-06-01 14:46:41 +02:00 committed by GitHub
commit e43e47c4f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 870 additions and 206 deletions

View File

@ -26,6 +26,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.resources.action.models import RateComputer, Snapshot, Trade from ereuse_devicehub.resources.action.models import RateComputer, Snapshot, Trade
from ereuse_devicehub.resources.action.rate.v1_0 import CannotRate from ereuse_devicehub.resources.action.rate.v1_0 import CannotRate
from ereuse_devicehub.resources.action.schemas import Snapshot as SnapshotSchema from ereuse_devicehub.resources.action.schemas import Snapshot as SnapshotSchema
@ -1098,3 +1099,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
@ -47,16 +49,12 @@ class DeviceListMix(GenericMixView):
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(
@ -66,7 +64,7 @@ class DeviceListMix(GenericMixView):
'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,
@ -400,6 +398,48 @@ class NewTradeDocumentView(View):
return flask.render_template(self.template_name, **self.context) return flask.render_template(self.template_name, **self.context)
class NewTransferView(GenericMixView):
methods = ['POST', 'GET']
template_name = 'inventory/new_transfer.html'
form_class = TransferForm
title = "Add new transfer"
def dispatch_request(self, lot_id, type_id):
self.form = self.form_class(lot_id=lot_id, type=type_id)
self.get_context()
if self.form.validate_on_submit():
self.form.save()
new_lot_id = lot_id
if self.form.newlot.id:
new_lot_id = "{}".format(self.form.newlot.id)
Lot.query.filter(Lot.id == new_lot_id).one()
messages.success('Transfer created successfully!')
next_url = url_for('inventory.lotdevicelist', lot_id=str(new_lot_id))
return flask.redirect(next_url)
self.context.update({'form': self.form, 'title': self.title})
return flask.render_template(self.template_name, **self.context)
class EditTransferView(GenericMixView):
methods = ['POST']
form_class = EditTransferForm
def dispatch_request(self, lot_id):
self.get_context()
form = self.form_class(request.form, lot_id=lot_id)
next_url = url_for('inventory.lotdevicelist', lot_id=lot_id)
if form.validate_on_submit():
form.save()
messages.success('Transfer updated successfully!')
return flask.redirect(next_url)
messages.error('Transfer updated error!')
return flask.redirect(next_url)
class ExportsView(View): class ExportsView(View):
methods = ['GET'] methods = ['GET']
decorators = [login_required] decorators = [login_required]
@ -557,3 +597,11 @@ devices.add_url_rule(
devices.add_url_rule( devices.add_url_rule(
'/export/<string:export_id>/', view_func=ExportsView.as_view('export') '/export/<string:export_id>/', view_func=ExportsView.as_view('export')
) )
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

@ -1,29 +1,34 @@
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 = {
'type': 'Trade', 'type': 'Trade',
'devices': [device_id], 'devices': [device_id],
'documents': [document_id], 'documents': [document_id],
'userFrom': user2.email, 'userFrom': user2.email,
'userTo': user.email, 'userTo': user.email,
'price': 10, 'price': 10,
'date': "2020-12-01T02:00:00+00:00", 'date': "2020-12-01T02:00:00+00:00",
'lot': lot['id'], 'lot': lot['id'],
'confirm': True, 'confirm': True,
} }
""" """
@ -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,15 +159,15 @@ class TradeView():
dev.change_owner(self.trade.user_to) dev.change_owner(self.trade.user_to)
class ConfirmMixin(): class ConfirmMixin:
""" """
Very Important: Very Important:
============== ==============
All of this Views than inherit of this class is executed for users All of this Views than inherit of this class is executed for users
than is not owner of the Trade action. than is not owner of the Trade action.
The owner of Trade action executed this actions of confirm and revoke from the The owner of Trade action executed this actions of confirm and revoke from the
lot lot
""" """
@ -167,24 +192,27 @@ class ConfirmMixin():
class ConfirmView(ConfirmMixin): class ConfirmView(ConfirmMixin):
"""Handler for manager the Confirmation register from post """Handler for manager the Confirmation register from post
request_confirm = { request_confirm = {
'type': 'Confirm', 'type': 'Confirm',
'action': trade.id, 'action': trade.id,
'devices': [device_id] 'devices': [device_id]
} }
""" """
Model = Confirm Model = Confirm
def validate(self, data): def validate(self, data):
"""If there are one device than have one confirmation, """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 = [] real_devices = []
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
@ -197,11 +225,11 @@ class ConfirmView(ConfirmMixin):
class RevokeView(ConfirmMixin): class RevokeView(ConfirmMixin):
"""Handler for manager the Revoke register from post """Handler for manager the Revoke register from post
request_revoke = { request_revoke = {
'type': 'Revoke', 'type': 'Revoke',
'action': trade.id, 'action': trade.id,
'devices': [device_id], 'devices': [device_id],
} }
""" """
@ -223,15 +251,15 @@ 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:
============== ==============
All of this Views than inherit of this class is executed for users All of this Views than inherit of this class is executed for users
than is not owner of the Trade action. than is not owner of the Trade action.
The owner of Trade action executed this actions of confirm and revoke from the The owner of Trade action executed this actions of confirm and revoke from the
lot lot
""" """
@ -256,18 +284,18 @@ class ConfirmDocumentMixin():
class ConfirmDocumentView(ConfirmDocumentMixin): class ConfirmDocumentView(ConfirmDocumentMixin):
"""Handler for manager the Confirmation register from post """Handler for manager the Confirmation register from post
request_confirm = { request_confirm = {
'type': 'Confirm', 'type': 'Confirm',
'action': trade.id, 'action': trade.id,
'documents': [document_id], 'documents': [document_id],
} }
""" """
Model = ConfirmDocument Model = ConfirmDocument
def validate(self, data): def validate(self, data):
"""If there are one device than have one confirmation, """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']: for doc in data['documents']:
ac = doc.trading ac = doc.trading
@ -280,11 +308,11 @@ class ConfirmDocumentView(ConfirmDocumentMixin):
class RevokeDocumentView(ConfirmDocumentMixin): class RevokeDocumentView(ConfirmDocumentMixin):
"""Handler for manager the Revoke register from post """Handler for manager the Revoke register from post
request_revoke = { request_revoke = {
'type': 'Revoke', 'type': 'Revoke',
'action': trade.id, 'action': trade.id,
'documents': [document_id], 'documents': [document_id],
} }
""" """
@ -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 ###
@ -307,11 +337,11 @@ class RevokeDocumentView(ConfirmDocumentMixin):
class ConfirmRevokeDocumentView(ConfirmDocumentMixin): class ConfirmRevokeDocumentView(ConfirmDocumentMixin):
"""Handler for manager the Confirmation register from post """Handler for manager the Confirmation register from post
request_confirm_revoke = { request_confirm_revoke = {
'type': 'ConfirmRevoke', 'type': 'ConfirmRevoke',
'action': action_revoke.id, 'action': action_revoke.id,
'documents': [document_id], 'documents': [document_id],
} }
""" """

View File

@ -5,12 +5,11 @@ 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
from sqlalchemy_utils.types.ltree import LQUERY from sqlalchemy_utils.types.ltree import LQUERY
from teal.db import CASCADE_OWN, UUIDLtree, check_range, IntEnum from teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
from teal.resource import url_for_resource from teal.resource import url_for_resource
from ereuse_devicehub.db import create_view, db, exp, f from ereuse_devicehub.db import create_view, db, exp, f
@ -21,70 +20,88 @@ from ereuse_devicehub.resources.user.models import User
class Lot(Thing): class Lot(Thing):
id = db.Column(UUID(as_uuid=True), primary_key=True) # uuid is generated on init by default id = db.Column(
UUID(as_uuid=True), primary_key=True
) # uuid is generated on init by default
name = db.Column(CIText(), nullable=False) name = db.Column(CIText(), nullable=False)
description = db.Column(CIText()) description = db.Column(CIText())
description.comment = """A comment about the lot.""" description.comment = """A comment about the lot."""
closed = db.Column(db.Boolean, default=False, nullable=False) closed = db.Column(db.Boolean, default=False, nullable=False)
closed.comment = """A closed lot cannot be modified anymore.""" closed.comment = """A closed lot cannot be modified anymore."""
devices = db.relationship(Device, devices = db.relationship(
backref=db.backref('lots', lazy=True, collection_class=set), Device,
secondary=lambda: LotDevice.__table__, backref=db.backref('lots', lazy=True, collection_class=set),
lazy=True, secondary=lambda: LotDevice.__table__,
collection_class=set) lazy=True,
collection_class=set,
)
"""The **children** devices that the lot has. """The **children** devices that the lot has.
Note that the lot can have more devices, if they are inside Note that the lot can have more devices, if they are inside
descendant lots. descendant lots.
""" """
parents = db.relationship(lambda: Lot, parents = db.relationship(
viewonly=True, lambda: Lot,
lazy=True, viewonly=True,
collection_class=set, lazy=True,
secondary=lambda: LotParent.__table__, collection_class=set,
primaryjoin=lambda: Lot.id == LotParent.child_id, secondary=lambda: LotParent.__table__,
secondaryjoin=lambda: LotParent.parent_id == Lot.id, primaryjoin=lambda: Lot.id == LotParent.child_id,
cascade='refresh-expire', # propagate changes outside ORM secondaryjoin=lambda: LotParent.parent_id == Lot.id,
backref=db.backref('children', cascade='refresh-expire', # propagate changes outside ORM
viewonly=True, backref=db.backref(
lazy=True, 'children',
cascade='refresh-expire', viewonly=True,
collection_class=set) lazy=True,
) cascade='refresh-expire',
collection_class=set,
),
)
"""The parent lots.""" """The parent lots."""
all_devices = db.relationship(Device, all_devices = db.relationship(
viewonly=True, Device,
lazy=True, viewonly=True,
collection_class=set, lazy=True,
secondary=lambda: LotDeviceDescendants.__table__, collection_class=set,
primaryjoin=lambda: Lot.id == LotDeviceDescendants.ancestor_lot_id, secondary=lambda: LotDeviceDescendants.__table__,
secondaryjoin=lambda: LotDeviceDescendants.device_id == Device.id) primaryjoin=lambda: Lot.id == LotDeviceDescendants.ancestor_lot_id,
secondaryjoin=lambda: LotDeviceDescendants.device_id == Device.id,
)
"""All devices, including components, inside this lot and its """All devices, including components, inside this lot and its
descendants. descendants.
""" """
amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0) amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0)
owner_id = db.Column(UUID(as_uuid=True), owner_id = db.Column(
db.ForeignKey(User.id), UUID(as_uuid=True),
nullable=False, db.ForeignKey(User.id),
default=lambda: g.user.id) nullable=False,
default=lambda: g.user.id,
)
owner = db.relationship(User, primaryjoin=owner_id == User.id) owner = db.relationship(User, primaryjoin=owner_id == User.id)
transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False) transfer_state = db.Column(
IntEnum(TransferState), default=TransferState.Initial, nullable=False
)
transfer_state.comment = TransferState.__doc__ transfer_state.comment = TransferState.__doc__
receiver_address = db.Column(CIText(), receiver_address = db.Column(
db.ForeignKey(User.email), CIText(),
nullable=False, db.ForeignKey(User.email),
default=lambda: g.user.email) nullable=False,
default=lambda: g.user.email,
)
receiver = db.relationship(User, primaryjoin=receiver_address == User.email) receiver = db.relationship(User, primaryjoin=receiver_address == User.email)
def __init__(self, name: str, closed: bool = closed.default.arg, def __init__(
description: str = None) -> None: self, name: str, closed: bool = closed.default.arg, description: str = None
) -> None:
"""Initializes a lot """Initializes a lot
:param name: :param name:
:param closed: :param closed:
""" """
super().__init__(id=uuid.uuid4(), name=name, closed=closed, description=description) super().__init__(
id=uuid.uuid4(), name=name, closed=closed, description=description
)
Path(self) # Lots have always one edge per default. Path(self) # Lots have always one edge per default.
@property @property
@ -102,20 +119,32 @@ 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):
return bool(self.trade and self.trade.user_to == current_user) if self.trade:
return self.trade.user_to == g.user
if self.transfer:
return self.transfer.user_to == g.user
return False
@property @property
def is_outgoing(self): def is_outgoing(self):
return bool(self.trade and self.trade.user_from == current_user) if self.trade:
return self.trade.user_from == g.user
if self.transfer:
return self.transfer.user_from == g.user
return False
@classmethod @classmethod
def descendantsq(cls, id): def descendantsq(cls, id):
_id = UUIDLtree.convert(id) _id = UUIDLtree.convert(id)
return (cls.id == Path.lot_id) & Path.path.lquery(exp.cast('*.{}.*'.format(_id), LQUERY)) return (cls.id == Path.lot_id) & Path.path.lquery(
exp.cast('*.{}.*'.format(_id), LQUERY)
)
@classmethod @classmethod
def roots(cls): def roots(cls):
@ -176,13 +205,17 @@ class Lot(Thing):
if isinstance(child, Lot): if isinstance(child, Lot):
return Path.has_lot(self.id, child.id) return Path.has_lot(self.id, child.id)
elif isinstance(child, Device): elif isinstance(child, Device):
device = db.session.query(LotDeviceDescendants) \ device = (
.filter(LotDeviceDescendants.device_id == child.id) \ db.session.query(LotDeviceDescendants)
.filter(LotDeviceDescendants.ancestor_lot_id == self.id) \ .filter(LotDeviceDescendants.device_id == child.id)
.filter(LotDeviceDescendants.ancestor_lot_id == self.id)
.one_or_none() .one_or_none()
)
return device return device
else: else:
raise TypeError('Lot only contains devices and lots, not {}'.format(child.__class__)) raise TypeError(
'Lot only contains devices and lots, not {}'.format(child.__class__)
)
def __repr__(self) -> str: def __repr__(self) -> str:
return '<Lot {0.name} devices={0.devices!r}>'.format(self) return '<Lot {0.name} devices={0.devices!r}>'.format(self)
@ -192,35 +225,44 @@ class LotDevice(db.Model):
device_id = db.Column(db.BigInteger, db.ForeignKey(Device.id), primary_key=True) device_id = db.Column(db.BigInteger, db.ForeignKey(Device.id), primary_key=True)
lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), primary_key=True) lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), primary_key=True)
created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
author_id = db.Column(UUID(as_uuid=True), author_id = db.Column(
db.ForeignKey(User.id), UUID(as_uuid=True),
nullable=False, db.ForeignKey(User.id),
default=lambda: g.user.id) nullable=False,
default=lambda: g.user.id,
)
author = db.relationship(User, primaryjoin=author_id == User.id) author = db.relationship(User, primaryjoin=author_id == User.id)
author_id.comment = """The user that put the device in the lot.""" author_id.comment = """The user that put the device in the lot."""
class Path(db.Model): class Path(db.Model):
id = db.Column(db.UUID(as_uuid=True), id = db.Column(
primary_key=True, db.UUID(as_uuid=True),
server_default=db.text('gen_random_uuid()')) primary_key=True,
server_default=db.text('gen_random_uuid()'),
)
lot_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False) lot_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
lot = db.relationship(Lot, lot = db.relationship(
backref=db.backref('paths', Lot,
lazy=True, backref=db.backref(
collection_class=set, 'paths', lazy=True, collection_class=set, cascade=CASCADE_OWN
cascade=CASCADE_OWN), ),
primaryjoin=Lot.id == lot_id) primaryjoin=Lot.id == lot_id,
)
path = db.Column(LtreeType, nullable=False) path = db.Column(LtreeType, nullable=False)
created = db.Column(db.TIMESTAMP(timezone=True), server_default=db.text('CURRENT_TIMESTAMP')) created = db.Column(
db.TIMESTAMP(timezone=True), server_default=db.text('CURRENT_TIMESTAMP')
)
created.comment = """When Devicehub created this.""" created.comment = """When Devicehub created this."""
__table_args__ = ( __table_args__ = (
# dag.delete_edge needs to disable internally/temporarily the unique constraint # dag.delete_edge needs to disable internally/temporarily the unique constraint
db.UniqueConstraint(path, name='path_unique', deferrable=True, initially='immediate'), db.UniqueConstraint(
path, name='path_unique', deferrable=True, initially='immediate'
),
db.Index('path_gist', path, postgresql_using='gist'), db.Index('path_gist', path, postgresql_using='gist'),
db.Index('path_btree', path, postgresql_using='btree'), db.Index('path_btree', path, postgresql_using='btree'),
db.Index('lot_id_index', lot_id, postgresql_using='hash') db.Index('lot_id_index', lot_id, postgresql_using='hash'),
) )
def __init__(self, lot: Lot) -> None: def __init__(self, lot: Lot) -> None:
@ -243,7 +285,9 @@ class Path(db.Model):
child_id = UUIDLtree.convert(child_id) child_id = UUIDLtree.convert(child_id)
return bool( return bool(
db.session.execute( db.session.execute(
"SELECT 1 from path where path ~ '*.{}.*.{}.*'".format(parent_id, child_id) "SELECT 1 from path where path ~ '*.{}.*.{}.*'".format(
parent_id, child_id
)
).first() ).first()
) )
@ -263,47 +307,73 @@ class LotDeviceDescendants(db.Model):
"""Ancestor lot table.""" """Ancestor lot table."""
_desc = Lot.__table__.alias() _desc = Lot.__table__.alias()
"""Descendant lot table.""" """Descendant lot table."""
lot_device = _desc \ lot_device = _desc.join(LotDevice, _desc.c.id == LotDevice.lot_id).join(
.join(LotDevice, _desc.c.id == LotDevice.lot_id) \ Path, _desc.c.id == Path.lot_id
.join(Path, _desc.c.id == Path.lot_id) )
"""Join: Path -- Lot -- LotDevice""" """Join: Path -- Lot -- LotDevice"""
descendants = "path.path ~ (CAST('*.'|| replace(CAST({}.id as text), '-', '_') " \ descendants = (
"|| '.*' AS LQUERY))".format(_ancestor.name) "path.path ~ (CAST('*.'|| replace(CAST({}.id as text), '-', '_') "
"|| '.*' AS LQUERY))".format(_ancestor.name)
)
"""Query that gets the descendants of the ancestor lot.""" """Query that gets the descendants of the ancestor lot."""
devices = db.select([ devices = (
LotDevice.device_id, db.select(
_desc.c.id.label('parent_lot_id'), [
_ancestor.c.id.label('ancestor_lot_id'), LotDevice.device_id,
None _desc.c.id.label('parent_lot_id'),
]).select_from(_ancestor).select_from(lot_device).where(db.text(descendants)) _ancestor.c.id.label('ancestor_lot_id'),
None,
]
)
.select_from(_ancestor)
.select_from(lot_device)
.where(db.text(descendants))
)
# Components # Components
_parent_device = Device.__table__.alias(name='parent_device') _parent_device = Device.__table__.alias(name='parent_device')
"""The device that has the access to the lot.""" """The device that has the access to the lot."""
lot_device_component = lot_device \ lot_device_component = lot_device.join(
.join(_parent_device, _parent_device.c.id == LotDevice.device_id) \ _parent_device, _parent_device.c.id == LotDevice.device_id
.join(Component, _parent_device.c.id == Component.parent_id) ).join(Component, _parent_device.c.id == Component.parent_id)
"""Join: Path -- Lot -- LotDevice -- ParentDevice (Device) -- Component""" """Join: Path -- Lot -- LotDevice -- ParentDevice (Device) -- Component"""
components = db.select([ components = (
Component.id.label('device_id'), db.select(
_desc.c.id.label('parent_lot_id'), [
_ancestor.c.id.label('ancestor_lot_id'), Component.id.label('device_id'),
LotDevice.device_id.label('device_parent_id'), _desc.c.id.label('parent_lot_id'),
]).select_from(_ancestor).select_from(lot_device_component).where(db.text(descendants)) _ancestor.c.id.label('ancestor_lot_id'),
LotDevice.device_id.label('device_parent_id'),
]
)
.select_from(_ancestor)
.select_from(lot_device_component)
.where(db.text(descendants))
)
__table__ = create_view('lot_device_descendants', devices.union(components)) __table__ = create_view('lot_device_descendants', devices.union(components))
class LotParent(db.Model): class LotParent(db.Model):
i = f.index(Path.path, db.func.text2ltree(f.replace(exp.cast(Path.lot_id, TEXT), '-', '_'))) i = f.index(
Path.path, db.func.text2ltree(f.replace(exp.cast(Path.lot_id, TEXT), '-', '_'))
)
__table__ = create_view( __table__ = create_view(
'lot_parent', 'lot_parent',
db.select([ db.select(
Path.lot_id.label('child_id'), [
exp.cast(f.replace(exp.cast(f.subltree(Path.path, i - 1, i), TEXT), '_', '-'), Path.lot_id.label('child_id'),
UUID).label('parent_id') exp.cast(
]).select_from(Path).where(i > 0), f.replace(
exp.cast(f.subltree(Path.path, i - 1, i), TEXT), '_', '-'
),
UUID,
).label('parent_id'),
]
)
.select_from(Path)
.where(i > 0),
) )

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)
Trade.user_to == g.user, .outerjoin(Transfer)
Lot.owner_id == g.user.id)) .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 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)
@ -175,15 +212,17 @@ class LotView(View):
class LotBaseChildrenView(View): class LotBaseChildrenView(View):
"""Base class for adding / removing children devices and """Base class for adding / removing children devices and
lots from a lot. lots from a lot.
""" """
def __init__(self, definition: 'Resource', **kw) -> None: def __init__(self, definition: 'Resource', **kw) -> None:
super().__init__(definition, **kw) super().__init__(definition, **kw)
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

@ -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">
@ -438,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

@ -60,6 +60,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/tag/devices/add/', '/inventory/tag/devices/add/',
'/inventory/tag/devices/{id}/del/', '/inventory/tag/devices/{id}/del/',

View File

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