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.inventory.models import (
DeliveryNote,
DeviceDocument,
ReceiverNote,
Transfer,
TransferCustomerDetails,
@ -110,6 +111,15 @@ DEVICES = {
"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']
MONITORS = ["ComputerMonitor", "Monitor", "TelevisionSet", "Projector"]
@ -1332,6 +1342,103 @@ class TradeDocumentForm(FlaskForm):
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):
lot_name = StringField(
'Lot Name',

View file

@ -1,8 +1,10 @@
from uuid import uuid4
from citext import CIText
from dateutil.tz import tzutc
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.orm import backref, relationship
from teal.db import CASCADE_OWN, URL
@ -110,3 +112,50 @@ class TransferCustomerDetails(Thing):
),
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,
CustomerDetailsForm,
DataWipeForm,
DeviceDocumentForm,
EditTransferForm,
FilterForm,
LotForm,
@ -810,6 +811,69 @@ class NewTradeView(DeviceListMixin, NewActionView):
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):
methods = ['POST', 'GET']
decorators = [login_required]
@ -832,7 +896,6 @@ class NewTradeDocumentView(GenericMixin):
class EditTransferDocumentView(GenericMixin):
decorators = [login_required]
methods = ['POST', 'GET']
template_name = 'inventory/trade_document.html'
@ -1554,6 +1617,18 @@ devices.add_url_rule(
devices.add_url_rule(
'/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(
'/lot/<string:lot_id>/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 'Placeholder'
@property
def documents(self):
docs = self.device.documents
if self.binding:
return docs.union(self.binding.documents)
return docs
class Computer(Device):
"""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>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#documents">Documents</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#lots">Lots</button>
</li>
@ -196,6 +200,81 @@
</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">
<h5 class="card-title">Status Details</h5>
<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/{id}/',
'/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/{orphans}/',
'/inventory/all/device/',

View file

@ -2774,3 +2774,82 @@ def test_reliable_device(user3: UserClientFlask):
assert Snapshot.query.first() == snapshot
assert len(snapshot.device.components) == 8
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