diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3ec6b9b135935f6425161cbcc18bdb3947eb6bfb..a88b56132eb4c7971bd08a32d01f20d1b00655cd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -46,6 +46,8 @@ test: services: - postgres:10 - redis:4.0 + - name: docker.elastic.co/elasticsearch/elasticsearch:6.4.1 + alias: elasticsearch before_script: - pip install -r requirements-dev.txt script: diff --git a/app/commands.py b/app/commands.py index 678ebaa04f019599e430edf9167c7dddf0dc8d0d..f1af4d2a8b67c7194779ea117bb7843dd9be35db 100644 --- a/app/commands.py +++ b/app/commands.py @@ -115,3 +115,9 @@ def register_cli(app): ) register_sentry(client, worker) worker.work() + + @app.cli.command() + def reindex(): + """Initialize the elasticsearch index""" + current_app.elasticsearch.indices.delete("*", ignore=404) + models.Item.reindex() diff --git a/app/inventory/views.py b/app/inventory/views.py index ef61248334e06b94fb707da87c515f446ce3047d..820dcd171842d7d56eb461bb5a0b7cf318eafb3b 100644 --- a/app/inventory/views.py +++ b/app/inventory/views.py @@ -30,90 +30,10 @@ from .. import utils, models bp = Blueprint("inventory", __name__) -@bp.route("/_retrieve_items") +@bp.route("/_retrieve_items", methods=["POST"]) @login_required def retrieve_items(): - # Get the parameters from the query string sent by datatables - draw = int(request.args.get("draw", 0)) - start = int(request.args.get("start", 0)) - per_page = int(request.args.get("length", 20)) - search = request.args.get("search[value]", "") - order_column = int(request.args.get("order[0][column]", 3)) - if request.args.get("order[0][dir]") == "desc": - order_dir = sa.desc - else: - order_dir = sa.asc - # Total number of items before filtering - nb_items_total = db.session.query(sa.func.count(models.Item.id)).scalar() - # Construct the query - query = models.Item.query - if search: - if "%" not in search: - search = f"%{search}%" - q1 = query.filter( - sa.or_( - models.Item.ics_id.like(search), models.Item.serial_number.like(search) - ) - ) - q2 = query.join(models.Item.manufacturer).filter( - models.Manufacturer.name.like(search) - ) - q3 = query.join(models.Item.model).filter( - sa.or_( - models.Model.name.like(search), models.Model.description.like(search) - ) - ) - q4 = query.join(models.Item.location).filter(models.Location.name.like(search)) - q5 = query.join(models.Item.status).filter(models.Status.name.like(search)) - q6 = query.join(models.Item.comments).filter( - models.ItemComment.body.like(search) - ) - query = q1.union(q2).union(q3).union(q4).union(q5).union(q6) - nb_items_filtered = query.order_by(None).count() - else: - nb_items_filtered = nb_items_total - # Construct the order_by query - columns = ( - "id", - "ics_id", - "created_at", - "updated_at", - "serial_number", - "quantity", - "manufacturer", - "model", - "location", - "status", - "parent", - ) - query = query.order_by(order_dir(getattr(models.Item, columns[order_column]))) - # Limit and offset the query - if per_page != -1: - query = query.limit(per_page) - query = query.offset(start) - data = [ - [ - item.id, - item.ics_id, - utils.format_field(item.created_at), - utils.format_field(item.updated_at), - item.serial_number, - item.quantity, - utils.format_field(item.manufacturer), - utils.format_field(item.model), - utils.format_field(item.location), - utils.format_field(item.status), - utils.format_field(item.parent), - ] - for item in query.all() - ] - response = { - "draw": draw, - "recordsTotal": nb_items_total, - "recordsFiltered": nb_items_filtered, - "data": data, - } - return jsonify(response) + return utils.retrieve_data_for_datatables(request.values, models.Item) @bp.route("/items") diff --git a/app/models.py b/app/models.py index 4832add6af07bc80d2a48b4bb82479538bdbf421..875410d588f9ac46858fd3f30b71fbf73e0a9028 100644 --- a/app/models.py +++ b/app/models.py @@ -15,6 +15,7 @@ import string import qrcode import itertools import urllib.parse +import elasticsearch import sqlalchemy as sa from enum import Enum from sqlalchemy.ext.declarative import declared_attr @@ -36,7 +37,7 @@ from .validators import ( DEVICE_TYPE_RE, TAG_RE, ) -from . import utils +from . import utils, search make_versioned(plugins=[FlaskUserPlugin()]) @@ -319,6 +320,62 @@ class User(db.Model, UserMixin): } +class SearchableMixin(object): + """Add search capability to a class""" + + @classmethod + def search(cls, query, page, per_page, sort=None): + try: + ids, total = search.query_index( + cls.__tablename__ + current_app.config["ELASTICSEARCH_INDEX_SUFFIX"], + query, + page, + per_page, + sort, + ) + except elasticsearch.ElasticsearchException as e: + # Invalid query + current_app.logger.warning(e) + return cls.query.filter_by(id=0), 0 + if total == 0: + return cls.query.filter_by(id=0), 0 + when = [(value, i) for i, value in enumerate(ids)] + return ( + cls.query.filter(cls.id.in_(ids)).order_by(db.case(when, value=cls.id)), + total, + ) + + @classmethod + def after_flush(cls, session, flush_context): + # Trigger the elasticsearch index update + # We don't use the after_commit event because calling + # model.to_dict() often requires to load some relationships + # which is not possible in the session state during after_commit + for obj in itertools.chain(session.new, session.dirty): + if isinstance(obj, SearchableMixin): + search.add_to_index( + obj.__tablename__ + + current_app.config["ELASTICSEARCH_INDEX_SUFFIX"], + obj, + ) + for obj in session.deleted: + if isinstance(obj, SearchableMixin): + search.remove_from_index( + obj.__tablename__ + + current_app.config["ELASTICSEARCH_INDEX_SUFFIX"], + obj, + ) + + @classmethod + def reindex(cls): + """Force to reindex all instances of the class""" + for obj in cls.query: + search.add_to_index( + cls.__tablename__ + current_app.config["ELASTICSEARCH_INDEX_SUFFIX"], + obj, + ) + + class Token(db.Model): """Table to store valid tokens""" @@ -438,7 +495,7 @@ class CreatedMixin: } -class Item(CreatedMixin, db.Model): +class Item(CreatedMixin, SearchableMixin, db.Model): __versioned__ = { "exclude": [ "created_at", @@ -1340,3 +1397,5 @@ def before_flush(session, flush_context, instances): # required by sqlalchemy_continuum sa.orm.configure_mappers() ItemVersion = version_class(Item) +# Set SQLAlchemy event listeners +db.event.listen(db.session, "after_flush", SearchableMixin.after_flush) diff --git a/app/search.py b/app/search.py new file mode 100644 index 0000000000000000000000000000000000000000..051692dc37efd4e0dbdcb198954a8eb50cb0c88d --- /dev/null +++ b/app/search.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +""" +app.search +~~~~~~~~~~ + +This module implements the search interface. + +:copyright: (c) 2018 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" +from flask import current_app + + +def add_to_index(index, model): + """Add a model instance to an index""" + if not current_app.elasticsearch: + return + body = model.to_dict() + # We remove the id key because it's used as the id of the document. + # Using the model id as document id allows to easily retrieve + # the model linked to a document + del body["id"] + current_app.elasticsearch.index( + index=index, + doc_type="_doc", + id=model.id, + body=body, + refresh=current_app.config["ELASTICSEARCH_REFRESH"], + ) + + +def remove_from_index(index, model): + """Remove a model from the index""" + if not current_app.elasticsearch: + return + current_app.elasticsearch.delete( + index=index, + doc_type="_doc", + id=model.id, + refresh=current_app.config["ELASTICSEARCH_REFRESH"], + ) + + +def query_index(index, query, page=1, per_page=20, sort=None): + """Run a search query on the index + + Return a BaseQuery (with the ids found) and the total number of hits as a tuple + """ + if not current_app.elasticsearch: + return [], 0 + kwargs = { + "index": index, + "doc_type": "_doc", + "q": query, + "from_": (page - 1) * per_page, + "size": per_page, + } + if sort is not None: + kwargs["sort"] = sort + search = current_app.elasticsearch.search(**kwargs) + ids = [int(hit["_id"]) for hit in search["hits"]["hits"]] + return ids, search["hits"]["total"] diff --git a/app/settings.py b/app/settings.py index 254a6e7a2b725daa725836bc757c9d8d9b18d357..fa946af37d433065ebcba4f29505302a54e5fe7b 100644 --- a/app/settings.py +++ b/app/settings.py @@ -37,6 +37,11 @@ REDIS_URL = "redis://redis:6379/2" QUEUES = ["default"] ELASTICSEARCH_URL = "http://elasticsearch:9200" +ELASTICSEARCH_INDEX_SUFFIX = "-dev" +# Shall only be set to "true" for testing to make +# documents visible for search immediately +# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-refresh.html +ELASTICSEARCH_REFRESH = "false" LDAP_HOST = "esss.lu.se" LDAP_BASE_DN = "DC=esss,DC=lu,DC=se" diff --git a/app/static/js/items.js b/app/static/js/items.js index 5eeccef6249f919644b0176639e5377c8aeda139..060647b55bf6ad67d77d47e51ea329db10ec6aa6 100644 --- a/app/static/js/items.js +++ b/app/static/js/items.js @@ -1,5 +1,14 @@ $(document).ready(function() { + function render_item_link(data) { + // render funtion to create link to Item view page + if ( data === null ) { + return data; + } + var url = $SCRIPT_ROOT + "/inventory/items/view/" + data; + return '<a href="'+ url + '">' + data + '</a>'; + } + // scroll up to avoid having the form input // hidden under the navbar if (location.hash == "#body") { @@ -59,35 +68,34 @@ $(document).ready(function() { "<'row'<'col-sm-12'tr>>" + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", "ajax": { - "url": $SCRIPT_ROOT + "/inventory/_retrieve_items" + "url": $SCRIPT_ROOT + "/inventory/_retrieve_items", + "type": "POST" }, "processing": true, "serverSide": true, "searchDelay": 500, - "order": [[3, 'desc']], "orderMulti": false, + "aaSorting": [], "pagingType": "full_numbers", "pageLength": 20, - "lengthMenu": [[20, 50, 100, -1], [20, 50, 100, "All"]], - "columnDefs": [ - { - "targets": [0], - "visible": false, - "searchable": false - }, - { - "targets": [6, 7, 8, 9], - "orderable": false + "lengthMenu": [[20, 50, 100], [20, 50, 100]], + "columns": [ + { data: 'ics_id', + render: function(data, type, row) { + return render_item_link(data); + } }, - { - "targets": [1, 10], - "render": function(data, type, row) { - // render funtion to create link to Item view page - if ( data === null ) { - return data; - } - var url = $SCRIPT_ROOT + "/inventory/items/view/" + data; - return '<a href="'+ url + '">' + data + '</a>'; + { data: 'created_at' }, + { data: 'updated_at' }, + { data: 'serial_number' }, + { data: 'quantity' }, + { data: 'manufacturer' }, + { data: 'model' }, + { data: 'location' }, + { data: 'status' }, + { data: 'parent', + render: function(data, type, row) { + return render_item_link(data); } } ] diff --git a/app/templates/inventory/items.html b/app/templates/inventory/items.html index 31f59bfcfd8731a4428cb0ebe9dc784f670f6529..7c13e2861018d85af1eddf6904d17214d1601703 100644 --- a/app/templates/inventory/items.html +++ b/app/templates/inventory/items.html @@ -22,7 +22,6 @@ <table id="items_table" class="table table-bordered table-hover table-sm" style="width:100%"> <thead> <tr> - <th>Id</th> <th>ICS id</th> <th>Created</th> <th>Updated</th> diff --git a/app/utils.py b/app/utils.py index a70a9e1f99274b61b9997a6ccd80b1ec0ed5d707..268160abefc8901c4b7223a6dbe2c13a710b1bbd 100644 --- a/app/utils.py +++ b/app/utils.py @@ -20,6 +20,7 @@ from pathlib import Path from flask import current_app, jsonify, url_for from flask.globals import _app_ctx_stack, _request_ctx_stack from flask_login import current_user +from .extensions import db def fetch_current_user_id(): @@ -279,3 +280,51 @@ def unique_filename(filename): break nb += 1 return unique + + +def retrieve_data_for_datatables(values, model): + """Return the filtered data of model to datatables + + This function is supposed to be called when using datatables + with serverSide processing. + + :param values: a `~werkzeug.datastructures.MultiDict`, typically request.values + :param model: class of the model to query + :return: json object required by datatables + """ + # Get the parameters from the post data sent by datatables + draw = values.get("draw", 0, type=int) + per_page = values.get("length", 20, type=int) + # page starts at 1 in elasticsearch + page = int(values.get("start", 0, type=int) / per_page) + 1 + search = values.get("search[value]", "") + if search == "": + search = "*" + order_column = values.get("order[0][column]") + if order_column is None: + sort = None + else: + name = values.get(f"columns[{order_column}][data]") + order_dir = values.get("order[0][dir]", "asc") + # For all fields of type text, sorting should be done + # on .keyword in elasticsearch + # quantity (in items table) is of type integer + # It's hard-coded here for now. If needed we can try to find a + # generic way to find this information. + if name == "quantity": + sort = f"{name}:{order_dir}" + else: + sort = f"{name}.keyword:{order_dir}" + instances, nb_filtered = model.search( + search, page=page, per_page=per_page, sort=sort + ) + data = [instance.to_dict() for instance in instances] + # Total number of items before filtering + nb_total = db.session.query(sa.func.count(model.id)).scalar() + response = { + "draw": draw, + "recordsTotal": nb_total, + "recordsFiltered": nb_filtered, + "data": data, + } + return jsonify(response) diff --git a/docs/_static/inventory/export_to_excel.png b/docs/_static/inventory/export_to_excel.png new file mode 100644 index 0000000000000000000000000000000000000000..ce6831139b5d8132d7223e3c8403c9b713032582 Binary files /dev/null and b/docs/_static/inventory/export_to_excel.png differ diff --git a/docs/_static/inventory/search_exists_parent.png b/docs/_static/inventory/search_exists_parent.png new file mode 100644 index 0000000000000000000000000000000000000000..3599f315900b8d6144f73f3cceac91107171f7db Binary files /dev/null and b/docs/_static/inventory/search_exists_parent.png differ diff --git a/docs/_static/inventory/search_ics_id_field.png b/docs/_static/inventory/search_ics_id_field.png new file mode 100644 index 0000000000000000000000000000000000000000..fcd0e9287062ef69311be65fa9436b4b7d044c09 Binary files /dev/null and b/docs/_static/inventory/search_ics_id_field.png differ diff --git a/docs/_static/inventory/search_ics_id_multiple.png b/docs/_static/inventory/search_ics_id_multiple.png new file mode 100644 index 0000000000000000000000000000000000000000..bc7a5645522d2c1000d346c908169e734fc4b9e1 Binary files /dev/null and b/docs/_static/inventory/search_ics_id_multiple.png differ diff --git a/docs/_static/inventory/search_juniper.png b/docs/_static/inventory/search_juniper.png new file mode 100644 index 0000000000000000000000000000000000000000..a7ea6e18d1bc037beeafdd6156dd873be7c323fe Binary files /dev/null and b/docs/_static/inventory/search_juniper.png differ diff --git a/docs/_static/inventory/search_juniper_not_rats.png b/docs/_static/inventory/search_juniper_not_rats.png new file mode 100644 index 0000000000000000000000000000000000000000..ea1d5efab0ec9edf69d6bafb337de2e30d6b73c4 Binary files /dev/null and b/docs/_static/inventory/search_juniper_not_rats.png differ diff --git a/docs/_static/inventory/search_quote_mac.png b/docs/_static/inventory/search_quote_mac.png new file mode 100644 index 0000000000000000000000000000000000000000..ee10090aa20d38e42ecc65c694e71ecf182b5b72 Binary files /dev/null and b/docs/_static/inventory/search_quote_mac.png differ diff --git a/docs/_static/inventory/search_quote_tag.png b/docs/_static/inventory/search_quote_tag.png new file mode 100644 index 0000000000000000000000000000000000000000..390be85a52812034e34f6198ede5922d339d81af Binary files /dev/null and b/docs/_static/inventory/search_quote_tag.png differ diff --git a/docs/_static/inventory/search_serial_number.png b/docs/_static/inventory/search_serial_number.png new file mode 100644 index 0000000000000000000000000000000000000000..9af6aaa3094e4cb6e240e14adca08cb1d5566205 Binary files /dev/null and b/docs/_static/inventory/search_serial_number.png differ diff --git a/docs/inventory.rst b/docs/inventory.rst index dd3cd23f70b803becf57cb9fe4941ae8a61836c4..e6044ea0e55e47375b3b90e162b521d42c8f4e56 100644 --- a/docs/inventory.rst +++ b/docs/inventory.rst @@ -15,25 +15,78 @@ An item can have a parent. Only **ICS id** and **serial number** fields are mandatory. The **serial number** is a free text field. -The *items* page of the inventory lists all existing items. You can find a specific item or goup of items -by using the search box. -The search is performed on the following fields: +The *items* page of the inventory lists all existing items. - - ICS id - - serial number - - manufacturer name - - model name - - location name - - status name - - item comments +.. image:: _static/inventory.png -Note that the search is case-sensitive! +Search +------ -.. image:: _static/inventory.png +You can find a specific item or group of items by using the search box. +Elasticsearch is used as the full-text search engine and allows to use a query string “mini-languageâ€. + +- To perform a free text search, simply enter a text string. For example, if you’re looking for Juniper devices, enter ``juniper``. Note that the search is case insensitive. + +.. image:: _static/inventory/search_juniper.png + +- You can search a serial number or ICS id. + +.. image:: _static/inventory/search_serial_number.png + +.. image:: _static/inventory/search_ics_id_multiple.png + +- As you can see on the above screenshot, by default the search is performed on all fields. The id ``AAA997`` was found both in the ics_id and parent fields. You can restrict the search to a specific field: ``ics_id:AAA997`` + +.. image:: _static/inventory/search_ics_id_field.png + +- By default, all terms are optional, as long as one term matches. A search for ``foo bar baz`` will find any document that contains one or more of foo or bar or baz. Boolean operators can be used to provide more control. The preferred operators are + (this term must be present) and - (this term must not be present). For example to search all juniper devices not at RATS: ``juniper -RATS`` + +.. image:: _static/inventory/search_juniper_not_rats.png + +- You can search items where a field has any non-null value. To get items with a parent: ``_exists_:parent`` + +.. image:: _static/inventory/search_exists_parent.png + +- To search a string that includes reserved characters (+ - = && || > < ! ( ) { } [ ] ^ " ~ * ? : \ /), you have to use double quotes. Here is how to search for an old JIRA TAG ``"TAG-250"`` or mac adress ``"7c:e2:ca:64:dd:61"``. + +.. image:: _static/inventory/search_quote_tag.png + +.. image:: _static/inventory/search_quote_mac.png + +The complete list of fields that can be searched is the following: + + - created_at + - updated_at + - user + - ics_id + - serial_number + - quantity + - manufacturer + - model + - location + - status + - parent + - children + - macs + - host + - stack_member + - history + - comments + +Check the elasticsearch `query string syntax`_ for more details. + + +Export items to Excel +--------------------- You can export all items to an excel file by clicking on the *Excel* button. Note that it might take some time depending on the number of items in the database. +.. image:: _static/inventory/export_to_excel.png + +View an item +------------- + Clicking on the **ICS id** of an item will take you to the *View item* page. .. image:: _static/view_item.png @@ -123,3 +176,6 @@ Scan the following codes to change the Xenon 1900 scanner settings: .. image:: _static/xenon/usb-serial.png :align: center + + +.. _query string syntax: https://www.elastic.co/guide/en/elasticsearch/reference/6.4/query-dsl-query-string-query.html#query-string-syntax diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 7bace7fd3ce3cac2079cf717bd08e4b8a5078eb9..1ff32a3d36832fd8a37859322d3e4bc85eea1d89 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -15,6 +15,7 @@ from pytest_factoryboy import register from flask_ldap3_login import AuthenticationResponse, AuthenticationResponseStatus from app.factory import create_app from app.extensions import db as _db +from app.models import SearchableMixin from . import common, factories register(factories.UserFactory) @@ -44,6 +45,8 @@ def app(request): "TESTING": True, "WTF_CSRF_ENABLED": False, "SQLALCHEMY_DATABASE_URI": "postgresql://ics:icspwd@postgres/csentry_db_test", + "ELASTICSEARCH_INDEX_SUFFIX": "-test", + "ELASTICSEARCH_REFRESH": "true", "CSENTRY_LDAP_GROUPS": { "admin": ["CSEntry Admin"], "create": ["CSEntry User", "CSEntry Consultant"], @@ -104,10 +107,16 @@ def session(db, request): session.expire_all() session.begin_nested() + # We have to register the after_flush event because we use a specific + # session to run the tests (not the same used in models.py) + db.event.listen(session(), "after_flush", SearchableMixin.after_flush) db.session = session yield session + # ELASTICSEARCH_INDEX_SUFFIX is set to "-test" + # Delete all "*-test" indices after each test + db.app.elasticsearch.indices.delete("*-test", ignore=404) session.remove() transaction.rollback() connection.close() diff --git a/tests/functional/test_search.py b/tests/functional/test_search.py new file mode 100644 index 0000000000000000000000000000000000000000..feb15a2bccd71fe2091251d21dddc86c08026861 --- /dev/null +++ b/tests/functional/test_search.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +""" +tests.functional.test_search +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module defines search tests. + +:copyright: (c) 2017 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" +import elasticsearch +import pytest +from app import search + + +class MyModel: + def __init__(self, id, name, description=""): + self.id = id + self.name = name + self.description = description + + def to_dict(self): + return {"id": self.id, "name": self.name, "description": self.description} + + +def test_add_to_index(db): + model1 = MyModel(2, "foo", "This is a test") + search.add_to_index("index-test", model1) + res = db.app.elasticsearch.get(index="index-test", doc_type="_doc", id=2) + assert res["_source"] == {"name": "foo", "description": "This is a test"} + + +def test_remove_from_index(db): + model1 = MyModel(3, "hello world!") + search.add_to_index("index-test", model1) + res = db.app.elasticsearch.search(index="index-test", q="*") + assert res["hits"]["total"] == 1 + search.remove_from_index("index-test", model1) + res = db.app.elasticsearch.search(index="index-test", q="*") + assert res["hits"]["total"] == 0 + + +def test_remove_from_index_non_existing(): + model1 = MyModel(1, "hello world!") + with pytest.raises(elasticsearch.NotFoundError): + search.remove_from_index("index-test", model1) + + +def test_query_index(): + model1 = MyModel(1, "Python", "Python is my favorite language") + search.add_to_index("index-test", model1) + model1 = MyModel(2, "Java", "Your should switch to Python!") + search.add_to_index("index-test", model1) + # Test query all + ids, total = search.query_index("index-test", "*") + assert sorted(ids) == [1, 2] + assert total == 2 + # Test query string + ids, total = search.query_index("index-test", "java") + assert ids == [2] + assert total == 1 + ids, total = search.query_index("index-test", "python") + assert sorted(ids) == [1, 2] + # Test query specific field + ids, total = search.query_index("index-test", "name:python") + assert ids == [1] + # Test query sort + ids, total = search.query_index("index-test", "*", sort="name.keyword") + assert ids == [2, 1] diff --git a/tests/functional/test_web.py b/tests/functional/test_web.py index 12878b0cc5343e03032a49419f6cb5aa0cb608e1..cd78c59e981eee46aefb112db2a32eca10676d24 100644 --- a/tests/functional/test_web.py +++ b/tests/functional/test_web.py @@ -46,10 +46,8 @@ def test_index(logged_client): assert b"User RO" in response.data -@pytest.mark.parametrize( - "url", ["/", "/inventory/items", "/inventory/_retrieve_items", "/network/networks"] -) -def test_protected_url(url, client): +@pytest.mark.parametrize("url", ["/", "/inventory/items", "/network/networks"]) +def test_protected_url_get(url, client): response = client.get(url) assert response.status_code == 302 assert "/user/login" in response.headers["Location"] @@ -58,16 +56,105 @@ def test_protected_url(url, client): assert response.status_code == 200 +@pytest.mark.parametrize("url", ["/inventory/_retrieve_items"]) +def test_protected_url_post(url, client): + response = client.post(url) + assert response.status_code == 302 + assert "/user/login" in response.headers["Location"] + login(client, "user_ro", "userro") + response = client.post(url) + assert response.status_code == 200 + + def test_retrieve_items(logged_client, item_factory): - response = logged_client.get("/inventory/_retrieve_items") + response = logged_client.post("/inventory/_retrieve_items") assert response.get_json()["data"] == [] serial_numbers = ("12345", "45678") for sn in serial_numbers: item_factory(serial_number=sn) - response = logged_client.get("/inventory/_retrieve_items") + response = logged_client.post("/inventory/_retrieve_items") + items = response.get_json()["data"] + assert set(serial_numbers) == set(item["serial_number"] for item in items) + assert len(items[0]) == 18 + + +def test_retrieve_items_pagination(logged_client, item_factory): + for sn in range(1000, 1030): + item_factory(serial_number=sn) + response = logged_client.post( + "/inventory/_retrieve_items", data={"draw": "50", "length": 10, "start": 0} + ) + r = response.get_json() + assert r["draw"] == 50 + assert r["recordsTotal"] == 30 + assert r["recordsFiltered"] == 30 + assert len(r["data"]) == 10 + serial_numbers = [item["serial_number"] for item in r["data"]] + response = logged_client.post( + "/inventory/_retrieve_items", data={"draw": "51", "length": 10, "start": 10} + ) + serial_numbers.extend( + [item["serial_number"] for item in response.get_json()["data"]] + ) + response = logged_client.post( + "/inventory/_retrieve_items", data={"draw": "52", "length": 10, "start": 20} + ) + serial_numbers.extend( + [item["serial_number"] for item in response.get_json()["data"]] + ) + assert sorted(serial_numbers) == list(str(i) for i in range(1000, 1030)) + + +def test_retrieve_items_filter(logged_client, item_factory): + for sn in range(1000, 1010): + item_factory(serial_number=sn) + response = logged_client.post( + "/inventory/_retrieve_items", + data={ + "draw": "50", + "length": 20, + "start": 0, + "search[value]": "serial_number:1005", + }, + ) + r = response.get_json() + assert r["recordsTotal"] == 10 + assert r["recordsFiltered"] == 1 + assert len(r["data"]) == 1 + assert r["data"][0]["serial_number"] == "1005" + + +def test_retrieve_items_sort(logged_client, item_factory): + serial_numbers = ["AAA001", "AAB034", "AAA100", "AAB001"] + for sn in serial_numbers: + item_factory(serial_number=sn) + response = logged_client.post( + "/inventory/_retrieve_items", + data={ + "draw": "50", + "length": 20, + "start": 0, + "order[0][column]": "3", + "columns[3][data]": "serial_number", + }, + ) + items = response.get_json()["data"] + assert sorted(serial_numbers) == [item["serial_number"] for item in items] + response = logged_client.post( + "/inventory/_retrieve_items", + data={ + "draw": "50", + "length": 20, + "start": 0, + "order[0][column]": "3", + "order[0][dir]": "desc", + "columns[3][data]": "serial_number", + }, + ) items = response.get_json()["data"] - assert set(serial_numbers) == set(item[4] for item in items) - assert len(items[0]) == 11 + assert sorted(serial_numbers, reverse=True) == [ + item["serial_number"] for item in items + ] def test_generate_random_mac(logged_client):