diff --git a/ereuse_devicehub/inventory/forms.py b/ereuse_devicehub/inventory/forms.py index bd5c5498..bbc0ea1b 100644 --- a/ereuse_devicehub/inventory/forms.py +++ b/ereuse_devicehub/inventory/forms.py @@ -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', diff --git a/ereuse_devicehub/inventory/models.py b/ereuse_devicehub/inventory/models.py index f8b4f977..1e51113e 100644 --- a/ereuse_devicehub/inventory/models.py +++ b/ereuse_devicehub/inventory/models.py @@ -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() + ) diff --git a/ereuse_devicehub/inventory/views.py b/ereuse_devicehub/inventory/views.py index a1481399..bb1ac9a9 100644 --- a/ereuse_devicehub/inventory/views.py +++ b/ereuse_devicehub/inventory/views.py @@ -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//document/add/', + view_func=NewDeviceDocumentView.as_view('device_document_add'), +) +devices.add_url_rule( + '/device//document/edit/', + view_func=EditDeviceDocumentView.as_view('device_document_edit'), +) +devices.add_url_rule( + '/device//document/del/', + view_func=DeviceDocumentDeleteView.as_view('device_document_del'), +) devices.add_url_rule( '/lot//transfer-document/add/', view_func=NewTradeDocumentView.as_view('transfer_document_add'), diff --git a/ereuse_devicehub/migrations/versions/ac476b60d952_add_document_device.py b/ereuse_devicehub/migrations/versions/ac476b60d952_add_document_device.py new file mode 100644 index 00000000..359f0b57 --- /dev/null +++ b/ereuse_devicehub/migrations/versions/ac476b60d952_add_document_device.py @@ -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()}') diff --git a/ereuse_devicehub/resources/device/models.py b/ereuse_devicehub/resources/device/models.py index b0a70c81..f8183e13 100644 --- a/ereuse_devicehub/resources/device/models.py +++ b/ereuse_devicehub/resources/device/models.py @@ -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 diff --git a/ereuse_devicehub/templates/inventory/device_detail.html b/ereuse_devicehub/templates/inventory/device_detail.html index 762f7cb1..c5e65e37 100644 --- a/ereuse_devicehub/templates/inventory/device_detail.html +++ b/ereuse_devicehub/templates/inventory/device_detail.html @@ -65,6 +65,10 @@ Web + + @@ -196,6 +200,81 @@ +
+ + +
Documents
+ + + + + + + + + + + {% for doc in placeholder.documents %} + + + + + + + {% endfor %} + +
FileUploaded on
+ {% if doc.get_url() %} + {{ doc.file_name}} + {% else %} + {{ doc.file_name}} + {% endif %} + + {{ doc.created.strftime('%Y-%m-%d %H:%M')}} + + + + + + + + + +
+
+
Status Details
diff --git a/ereuse_devicehub/templates/inventory/device_document.html b/ereuse_devicehub/templates/inventory/device_document.html new file mode 100644 index 00000000..1991444f --- /dev/null +++ b/ereuse_devicehub/templates/inventory/device_document.html @@ -0,0 +1,70 @@ +{% extends "ereuse_devicehub/base_site.html" %} +{% block main %} + +
+

{{ title }}

+ +
+ +
+
+
+ +
+
+ +
+
{{ title }}
+ {% if form.form_errors %} +

+ {% for error in form.form_errors %} + {{ error }}
+ {% endfor %} +

+ {% endif %} +
+ + {% if form._obj or 1==2 %} +
+ {% else %} + + {% endif %} + {{ form.csrf_token }} + {% for field in form %} + {% if field != form.csrf_token %} +
+ {{ field.label(class_="form-label") }} + {{ field }} + {{ field.description }} + {% if field.errors %} +

+ {% for error in field.errors %} + {{ error }}
+ {% endfor %} +

+ {% endif %} +
+ {% endif %} + {% endfor %} + +
+ Cancel + +
+
+ +
+
+ +
+
+
+{% endblock main %} diff --git a/tests/test_basic.py b/tests/test_basic.py index e834cba2..bf78792d 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -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/', diff --git a/tests/test_render_2_0.py b/tests/test_render_2_0.py index bf127c14..d2842429 100644 --- a/tests/test_render_2_0.py +++ b/tests/test_render_2_0.py @@ -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