Merge pull request #443 from eReuse/feature/4109-documents-in-device

Feature/4109 documents in device
This commit is contained in:
cayop 2023-04-17 11:41:12 +02:00 committed by GitHub
commit 2e0173b7dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 571 additions and 2 deletions

View file

@ -32,6 +32,7 @@ from wtforms.fields import FormField
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.inventory.models import ( from ereuse_devicehub.inventory.models import (
DeliveryNote, DeliveryNote,
DeviceDocument,
ReceiverNote, ReceiverNote,
Transfer, Transfer,
TransferCustomerDetails, TransferCustomerDetails,
@ -110,6 +111,15 @@ DEVICES = {
"Other Devices": ["Other"], "Other Devices": ["Other"],
} }
TYPES_DOCUMENTS = [
("", ""),
("image", "Image"),
("main_image", "Main Image"),
("functionality_report", "Functionality Report"),
("data_sanitization_report", "Data Sanitization Report"),
("disposition_report", "Disposition Report"),
]
COMPUTERS = ['Desktop', 'Laptop', 'Server', 'Computer'] COMPUTERS = ['Desktop', 'Laptop', 'Server', 'Computer']
MONITORS = ["ComputerMonitor", "Monitor", "TelevisionSet", "Projector"] MONITORS = ["ComputerMonitor", "Monitor", "TelevisionSet", "Projector"]
@ -1332,6 +1342,103 @@ class TradeDocumentForm(FlaskForm):
return self._obj return self._obj
class DeviceDocumentForm(FlaskForm):
url = URLField(
'Url',
[validators.Optional()],
render_kw={'class': "form-control"},
description="Url where the document resides",
)
description = StringField(
'Description',
[validators.Optional()],
render_kw={'class': "form-control"},
description="",
)
id_document = StringField(
'Document Id',
[validators.Optional()],
render_kw={'class': "form-control"},
description="Identification number of document",
)
type = SelectField(
'Type',
[validators.Optional()],
choices=TYPES_DOCUMENTS,
default="",
render_kw={'class': "form-select"},
)
date = DateField(
'Date',
[validators.Optional()],
render_kw={'class': "form-control"},
description="",
)
file_name = FileField(
'File',
[validators.DataRequired()],
render_kw={'class': "form-control"},
description="""This file is not stored on our servers, it is only used to
generate a digital signature and obtain the name of the file.""",
)
def __init__(self, *args, **kwargs):
id = kwargs.pop('dhid')
doc_id = kwargs.pop('document', None)
self._device = Device.query.filter(Device.devicehub_id == id).first()
self._obj = None
if doc_id:
self._obj = DeviceDocument.query.filter_by(
id=doc_id, device=self._device, owner=g.user
).one()
kwargs['obj'] = self._obj
super().__init__(*args, **kwargs)
if self._obj:
if isinstance(self.url.data, URL):
self.url.data = self.url.data.to_text()
def validate(self, extra_validators=None):
is_valid = super().validate(extra_validators)
if g.user != self._device.owner:
is_valid = False
return is_valid
def save(self, commit=True):
file_name = ''
file_hash = ''
if self.file_name.data:
file_name = self.file_name.data.filename
file_hash = insert_hash(self.file_name.data.read(), commit=False)
self.url.data = URL(self.url.data)
if not self._obj:
self._obj = DeviceDocument(device_id=self._device.id)
self.populate_obj(self._obj)
self._obj.file_name = file_name
self._obj.file_hash = file_hash
if not self._obj.id:
db.session.add(self._obj)
# self._device.documents.add(self._obj)
if commit:
db.session.commit()
return self._obj
def remove(self):
if self._obj:
self._obj.delete()
db.session.commit()
return self._obj
class TransferForm(FlaskForm): class TransferForm(FlaskForm):
lot_name = StringField( lot_name = StringField(
'Lot Name', 'Lot Name',

View file

@ -1,8 +1,10 @@
from uuid import uuid4 from uuid import uuid4
from citext import CIText from citext import CIText
from dateutil.tz import tzutc
from flask import g from flask import g
from sqlalchemy import Column, Integer from sortedcontainers import SortedSet
from sqlalchemy import BigInteger, Column, Integer
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import backref, relationship from sqlalchemy.orm import backref, relationship
from teal.db import CASCADE_OWN, URL from teal.db import CASCADE_OWN, URL
@ -110,3 +112,50 @@ class TransferCustomerDetails(Thing):
), ),
primaryjoin='TransferCustomerDetails.transfer_id == Transfer.id', primaryjoin='TransferCustomerDetails.transfer_id == Transfer.id',
) )
_sorted_documents = {
'order_by': lambda: DeviceDocument.created,
'collection_class': SortedSet,
}
class DeviceDocument(Thing):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
type = Column(db.CIText(), nullable=True)
date = Column(db.DateTime, nullable=True)
id_document = Column(db.CIText(), nullable=True)
description = Column(db.CIText(), nullable=True)
owner_id = db.Column(
UUID(as_uuid=True),
db.ForeignKey(User.id),
nullable=False,
default=lambda: g.user.id,
)
owner = db.relationship(User, primaryjoin=owner_id == User.id)
device_id = db.Column(BigInteger, db.ForeignKey('device.id'), nullable=False)
device = db.relationship(
'Device',
primaryjoin='DeviceDocument.device_id == Device.id',
backref=backref(
'documents', lazy=True, cascade=CASCADE_OWN, **_sorted_documents
),
)
file_name = Column(db.CIText(), nullable=True)
file_hash = Column(db.CIText(), nullable=True)
url = db.Column(URL(), nullable=True)
# __table_args__ = (
# db.Index('document_id', id, postgresql_using='hash'),
# db.Index('type_doc', type, postgresql_using='hash')
# )
def get_url(self) -> str:
if self.url:
return self.url.to_text()
return ''
def __lt__(self, other):
return self.created.replace(tzinfo=tzutc()) < other.created.replace(
tzinfo=tzutc()
)

View file

@ -24,6 +24,7 @@ from ereuse_devicehub.inventory.forms import (
BindingForm, BindingForm,
CustomerDetailsForm, CustomerDetailsForm,
DataWipeForm, DataWipeForm,
DeviceDocumentForm,
EditTransferForm, EditTransferForm,
FilterForm, FilterForm,
LotForm, LotForm,
@ -810,6 +811,69 @@ class NewTradeView(DeviceListMixin, NewActionView):
return flask.redirect(next_url) return flask.redirect(next_url)
class NewDeviceDocumentView(GenericMixin):
methods = ['POST', 'GET']
decorators = [login_required]
template_name = 'inventory/device_document.html'
form_class = DeviceDocumentForm
title = "Add new document"
def dispatch_request(self, dhid):
self.form = self.form_class(dhid=dhid)
self.get_context()
if self.form.validate_on_submit():
self.form.save()
messages.success('Document created successfully!')
next_url = url_for('inventory.device_details', id=dhid)
return flask.redirect(next_url)
self.context.update({'form': self.form, 'title': self.title})
return flask.render_template(self.template_name, **self.context)
class EditDeviceDocumentView(GenericMixin):
decorators = [login_required]
methods = ['POST', 'GET']
template_name = 'inventory/device_document.html'
form_class = DeviceDocumentForm
title = "Edit document"
def dispatch_request(self, dhid, doc_id):
self.form = self.form_class(dhid=dhid, document=doc_id)
self.get_context()
if self.form.validate_on_submit():
self.form.save()
messages.success('Edit document successfully!')
next_url = url_for('inventory.device_details', id=dhid)
return flask.redirect(next_url)
self.context.update({'form': self.form, 'title': self.title})
return flask.render_template(self.template_name, **self.context)
class DeviceDocumentDeleteView(View):
methods = ['GET']
decorators = [login_required]
template_name = 'inventory/device_detail.html'
form_class = DeviceDocumentForm
def dispatch_request(self, dhid, doc_id):
self.form = self.form_class(dhid=dhid, document=doc_id)
next_url = url_for('inventory.device_details', id=dhid)
try:
self.form.remove()
except Exception as err:
msg = "{}".format(err)
messages.error(msg)
return flask.redirect(next_url)
msg = "Document removed successfully."
messages.success(msg)
return flask.redirect(next_url)
class NewTradeDocumentView(GenericMixin): class NewTradeDocumentView(GenericMixin):
methods = ['POST', 'GET'] methods = ['POST', 'GET']
decorators = [login_required] decorators = [login_required]
@ -832,7 +896,6 @@ class NewTradeDocumentView(GenericMixin):
class EditTransferDocumentView(GenericMixin): class EditTransferDocumentView(GenericMixin):
decorators = [login_required] decorators = [login_required]
methods = ['POST', 'GET'] methods = ['POST', 'GET']
template_name = 'inventory/trade_document.html' template_name = 'inventory/trade_document.html'
@ -1554,6 +1617,18 @@ devices.add_url_rule(
devices.add_url_rule( devices.add_url_rule(
'/action/datawipe/add/', view_func=NewDataWipeView.as_view('datawipe_add') '/action/datawipe/add/', view_func=NewDataWipeView.as_view('datawipe_add')
) )
devices.add_url_rule(
'/device/<string:dhid>/document/add/',
view_func=NewDeviceDocumentView.as_view('device_document_add'),
)
devices.add_url_rule(
'/device/<string:dhid>/document/edit/<string:doc_id>',
view_func=EditDeviceDocumentView.as_view('device_document_edit'),
)
devices.add_url_rule(
'/device/<string:dhid>/document/del/<string:doc_id>',
view_func=DeviceDocumentDeleteView.as_view('device_document_del'),
)
devices.add_url_rule( devices.add_url_rule(
'/lot/<string:lot_id>/transfer-document/add/', '/lot/<string:lot_id>/transfer-document/add/',
view_func=NewTradeDocumentView.as_view('transfer_document_add'), view_func=NewTradeDocumentView.as_view('transfer_document_add'),

View file

@ -0,0 +1,100 @@
"""add document device
Revision ID: ac476b60d952
Revises: 4f33137586dd
Create Date: 2023-03-31 10:46:02.463007
"""
import citext
import sqlalchemy as sa
import teal
from alembic import context, op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'ac476b60d952'
down_revision = '4f33137586dd'
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():
op.create_table(
'device_document',
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(
'type',
citext.CIText(),
nullable=True,
),
sa.Column(
'date',
sa.DateTime(),
nullable=True,
),
sa.Column(
'id_document',
citext.CIText(),
nullable=True,
),
sa.Column(
'description',
citext.CIText(),
nullable=True,
),
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('device_id', sa.BigInteger(), nullable=False),
sa.Column(
'file_name',
citext.CIText(),
nullable=True,
),
sa.Column(
'file_hash',
citext.CIText(),
nullable=True,
),
sa.Column(
'url',
citext.CIText(),
teal.db.URL(),
nullable=True,
),
sa.ForeignKeyConstraint(
['device_id'],
[f'{get_inv()}.device.id'],
),
sa.ForeignKeyConstraint(
['owner_id'],
['common.user.id'],
),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}',
)
def downgrade():
op.drop_table('device_document', schema=f'{get_inv()}')

View file

@ -1232,6 +1232,13 @@ class Placeholder(Thing):
return 'Twin' return 'Twin'
return 'Placeholder' return 'Placeholder'
@property
def documents(self):
docs = self.device.documents
if self.binding:
return docs.union(self.binding.documents)
return docs
class Computer(Device): class Computer(Device):
"""A chassis with components inside that can be processed """A chassis with components inside that can be processed

View file

@ -65,6 +65,10 @@
<a class="nav-link" href="{{ device.public_link }}" target="_blank">Web</a> <a class="nav-link" href="{{ device.public_link }}" target="_blank">Web</a>
</li> </li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#documents">Documents</button>
</li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#lots">Lots</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#lots">Lots</button>
</li> </li>
@ -196,6 +200,81 @@
</div> </div>
</div> </div>
<div class="tab-pane fade profile-overview" id="documents">
<div class="btn-group dropdown ml-1 mt-1" uib-dropdown="">
<a href="{{ url_for('inventory.device_document_add', dhid=placeholder.device.devicehub_id) }}" class="btn btn-primary">
<i class="bi bi-plus"></i>
Add new document
<span class="caret"></span>
</a>
</div>
<h5 class="card-title">Documents</h5>
<table class="table">
<thead>
<tr>
<th scope="col">File</th>
<th scope="col" data-type="date" data-format="YYYY-MM-DD hh:mm">Uploaded on</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{% for doc in placeholder.documents %}
<tr>
<td>
{% if doc.get_url() %}
<a href="{{ doc.get_url() }}" target="_blank">{{ doc.file_name}}</a>
{% else %}
{{ doc.file_name}}
{% endif %}
</td>
<td>
{{ doc.created.strftime('%Y-%m-%d %H:%M')}}
</td>
<td>
<a href="{{ url_for('inventory.device_document_edit', dhid=doc.device.dhid, doc_id=doc.id) }}" title="Edit document">
<i class="bi bi-pencil-square"></i>
</a>
</td>
<td>
<a href="javascript:javascript:void(0)" data-bs-toggle="modal" data-bs-target="#btnRemoveDocument{{ loop.index }}" title="Remove document">
<i class="bi bi-trash-fill"></i>
</a>
<div class="modal fade" id="btnRemoveDocument{{ loop.index }}" tabindex="-1" style="display: none;" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete Document</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure that you want to delete this Document?<br />
<strong>{{ doc.file_name }}</strong>
<p class="text-danger">
This action cannot be undone.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary-outline" data-bs-dismiss="modal">Cancel</button>
<a href="{{ url_for('inventory.device_document_del', dhid=doc.device.dhid, doc_id=doc.id) }}" type="button" class="btn btn-danger">
Delete it!
</a>
</div>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane fade profile-overview" id="status"> <div class="tab-pane fade profile-overview" id="status">
<h5 class="card-title">Status Details</h5> <h5 class="card-title">Status Details</h5>
<div class="row"> <div class="row">

View file

@ -0,0 +1,70 @@
{% extends "ereuse_devicehub/base_site.html" %}
{% block main %}
<div class="pagetitle">
<h1>{{ title }}</h1>
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#TODO-lot-list">Inventory</a></li>
<li class="breadcrumb-item"><a href="#TODO-lot-list">Device {{ form._device.dhid }}</a></li>
<li class="breadcrumb-item">Document</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>
{% if form._obj or 1==2 %}
<form action="{{ url_for('inventory.device_document_edit', dhid=form._device.dhid, doc_id=form._obj.id) }}" method="post"
class="row g-3 needs-validation" enctype="multipart/form-data">
{% else %}
<form action="{{ url_for('inventory.device_document_add', dhid=form._device.dhid) }}" method="post"
class="row g-3 needs-validation" enctype="multipart/form-data">
{% endif %}
{{ form.csrf_token }}
{% for field in form %}
{% if field != form.csrf_token %}
<div>
{{ field.label(class_="form-label") }}
{{ field }}
<small class="text-muted">{{ field.description }}</small>
{% if field.errors %}
<p class="text-danger">
{% for error in field.errors %}
{{ error }}<br/>
{% endfor %}
</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
<div>
<a href="{{ url_for('inventory.device_details', id=form._device.dhid) }}" class="btn btn-danger">Cancel</a>
<button class="btn btn-primary" type="submit">Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
</section>
{% endblock main %}

View file

@ -55,6 +55,9 @@ def test_api_docs(client: Client):
'/inventory/device/add/', '/inventory/device/add/',
'/inventory/device/{id}/', '/inventory/device/{id}/',
'/inventory/device/{dhid}/binding/', '/inventory/device/{dhid}/binding/',
'/inventory/device/{dhid}/document/del/{doc_id}',
'/inventory/device/{dhid}/document/edit/{doc_id}',
'/inventory/device/{dhid}/document/add/',
'/inventory/device/erasure/', '/inventory/device/erasure/',
'/inventory/device/erasure/{orphans}/', '/inventory/device/erasure/{orphans}/',
'/inventory/all/device/', '/inventory/all/device/',

View file

@ -2774,3 +2774,82 @@ def test_reliable_device(user3: UserClientFlask):
assert Snapshot.query.first() == snapshot assert Snapshot.query.first() == snapshot
assert len(snapshot.device.components) == 8 assert len(snapshot.device.components) == 8
assert len(snapshot.device.actions) == 7 assert len(snapshot.device.actions) == 7
@pytest.mark.mvp
@pytest.mark.usefixtures(conftest.app_context.__name__)
def test_add_device_document(user3: UserClientFlask):
snapshot = create_device(user3, 'real-eee-1001pxd.snapshot.12.json')
device = Device.query.filter_by(devicehub_id=snapshot.device.dhid).one()
uri = '/inventory/device/{}/document/add/'.format(device.dhid)
user3.get(uri)
name = "doc1.pdf"
url = "https://www.usody.com/"
file_name = (BytesIO(b'1234567890'), name)
data = {
'url': url,
'file_name': file_name,
'csrf_token': generate_csrf(),
}
user3.post(uri, data=data, content_type="multipart/form-data")
assert device.documents[0].file_name == name
assert device.documents[0].url.to_text() == url
@pytest.mark.mvp
@pytest.mark.usefixtures(conftest.app_context.__name__)
def test_edit_device_document(user3: UserClientFlask):
snapshot = create_device(user3, 'real-eee-1001pxd.snapshot.12.json')
device = Device.query.filter_by(devicehub_id=snapshot.device.dhid).one()
uri = '/inventory/device/{}/document/add/'.format(device.dhid)
user3.get(uri)
name = "doc1.pdf"
url = "https://www.usody.com/"
file_name = (BytesIO(b'1234567890'), name)
data = {
'url': url,
'file_name': file_name,
'csrf_token': generate_csrf(),
}
user3.post(uri, data=data, content_type="multipart/form-data")
doc_id = str(device.documents[0].id)
uri = '/inventory/device/{}/document/edit/{}'.format(device.dhid, doc_id)
user3.get(uri)
data['url'] = "https://www.ereuse.org/"
data['csrf_token'] = generate_csrf()
data['file_name'] = (BytesIO(b'1234567890'), name)
user3.post(uri, data=data, content_type="multipart/form-data")
assert device.documents[0].file_name == name
assert device.documents[0].url.to_text() == data['url']
@pytest.mark.mvp
@pytest.mark.usefixtures(conftest.app_context.__name__)
def test_delete_device_document(user3: UserClientFlask):
snapshot = create_device(user3, 'real-eee-1001pxd.snapshot.12.json')
device = Device.query.filter_by(devicehub_id=snapshot.device.dhid).one()
uri = '/inventory/device/{}/document/add/'.format(device.dhid)
user3.get(uri)
name = "doc1.pdf"
url = "https://www.usody.com/"
file_name = (BytesIO(b'1234567890'), name)
data = {
'url': url,
'file_name': file_name,
'csrf_token': generate_csrf(),
}
user3.post(uri, data=data, content_type="multipart/form-data")
doc_id = str(device.documents[0].id)
uri = '/inventory/device/{}/document/del/{}'.format(device.dhid, doc_id)
user3.get(uri)
assert len(device.documents) == 0