diff --git a/.gitignore b/.gitignore index 73ace0515b1532052aef03b6796c1453114b3b4b..b5d81dd8ede298c45a069138e8d395bac9f642c7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ coverage.xml junit.xml .pytest_cache .tower_cli.cfg +/app/static/files/* +!/app/static/files/.empty diff --git a/app/inventory/views.py b/app/inventory/views.py index 60efbb15a0942a6dff94614b948c8e1b56d05eea..ef61248334e06b94fb707da87c515f446ce3047d 100644 --- a/app/inventory/views.py +++ b/app/inventory/views.py @@ -122,6 +122,16 @@ def list_items(): return render_template("inventory/items.html") +@bp.route("/items/_generate_excel_file") +@login_required +def _generate_excel_file(): + task = current_user.launch_task( + "generate_items_excel_file", func="generate_items_excel_file", timeout=180 + ) + db.session.commit() + return utils.redirect_to_job_status(task.id) + + @bp.route("/items/create", methods=("GET", "POST")) @login_groups_accepted("admin", "create") def create_item(): diff --git a/app/main/views.py b/app/main/views.py index c52f4b0188cc549d231a1c557951880da94a25e8..5e5171c21588f1990e39a12c5b1f90335106bacc 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -14,7 +14,7 @@ import redis import rq_dashboard from flask import Blueprint, render_template, jsonify, g, current_app, abort from flask_login import login_required, current_user -from rq import push_connection, pop_connection +from rq import push_connection, pop_connection, Queue from ..extensions import sentry from .. import utils @@ -98,3 +98,27 @@ def pop_rq_connection(exception=None): @login_required def index(): return render_template("index.html") + + +@bp.route("/status/<job_id>") +@login_required +def job_status(job_id): + """Return the status of the job + + :param int job_id: The id of the job to pull + :return: json with the status of the job + """ + q = Queue() + job = q.fetch_job(job_id) + if job is None: + response = {"status": "unknown"} + else: + response = { + "status": job.get_status(), + "result": job.result, + "func_name": job.func_name.split(".")[-1], + "progress": job.meta.get("progress"), + } + if job.is_failed: + response["message"] = job.meta.get("error", "Job failed") + return jsonify(response) diff --git a/app/models.py b/app/models.py index bfa25575d33e78a33d78c837712d4c7809e66d1f..0a5eed59ac93192e7a4345b52d46af35fb100ce2 100644 --- a/app/models.py +++ b/app/models.py @@ -512,6 +512,18 @@ class Item(CreatedMixin, db.Model): ) return d + def to_row_dict(self): + """Convert to a dict that can easily be exported to an excel row + + All values should be a string + """ + d = self.to_dict().copy() + d["children"] = " ".join(d["children"]) + d["macs"] = " ".join(d["macs"]) + d["comments"] = "\n\n".join(d["comments"]) + d["history"] = "\n".join([str(version) for version in d["history"]]) + return d + def history(self): versions = [] for version in self.versions: diff --git a/app/settings.py b/app/settings.py index c9db033affa848292b2408e224bf6e95f5d94606..14784a87c4cb46edf2e9a6fd0e73dd7ad3340216 100644 --- a/app/settings.py +++ b/app/settings.py @@ -94,3 +94,6 @@ CSENTRY_RELEASE = raven.fetch_git_sha(Path(__file__).parents[1]) SENTRY_DSN = os.environ.get("SENTRY_DSN", "") SENTRY_USER_ATTRS = ["username"] SENTRY_CONFIG = {"release": CSENTRY_RELEASE} + +# Static local files +CSENTRY_STATIC_FILES = Path(__file__).parent / "static" / "files" diff --git a/app/static/files/.empty b/app/static/files/.empty new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/static/js/csentry.js b/app/static/js/csentry.js index 57274001b55807e27504d7d7fc8db928cfc97d8c..c3f223c83d566d178cd71c77fcde6837ddc27432 100644 --- a/app/static/js/csentry.js +++ b/app/static/js/csentry.js @@ -11,6 +11,54 @@ function remove_alerts() { }); } +function update_progress_bar(value) { + // Assume that there is only one progress bar + $(".progress-bar") + .css("width", value + "%") + .attr("aria-valuenow", value) + .text(value + "%"); +} + +// Send a request to the server every 500 ms +// until the job is done +function check_job_status(status_url, $modal) { + // cache shall be set to false for this to work in IE! + $.ajax({ + dataType: "json", + url: status_url, + cache: false, + success: function(data, status, request) { + switch (data.status) { + case "unknown": + $modal.modal('hide'); + flash_alert("Unknown job id", "danger"); + break; + case "finished": + $modal.modal('hide'); + switch (data.func_name) { + case "generate_items_excel_file": + window.location.href = $SCRIPT_ROOT + "/static/files/" + data.result; + break; + } + break; + case "failed": + $modal.modal('hide'); + flash_alert(data.message, "danger"); + break; + case "started": + if( data.progress !== null ) { + update_progress_bar(data.progress); + } + default: + // queued/started/deferred + setTimeout(function() { + check_job_status(status_url, $modal); + }, 500); + } + } + }); +} + // Function to dynamically update a select field function update_selectfield(field_id, data, selected_value) { var $field = $(field_id); diff --git a/app/static/js/items.js b/app/static/js/items.js index 0574112d2050b53b062d37f912eda1400e7dbfe4..572918819a1b7c18ad771e46bc30ae5409889b3e 100644 --- a/app/static/js/items.js +++ b/app/static/js/items.js @@ -32,7 +32,32 @@ $(document).ready(function() { $(this).html(converter.makeHtml(raw)); }); + + // export all items to excel file + function download_excel() { + var $modal = $("#downloadExcelModal"); + $.ajax({ + url: $SCRIPT_ROOT + "/inventory/items/_generate_excel_file", + method: "GET", + success: function(data, status, request) { + $modal.modal({ + backdrop: "static", + keyboard: false + }); + status_url = request.getResponseHeader('Location'); + check_job_status(status_url, $modal); + }, + error: function(jqXHR, textStatus, errorThrown) { + $modal.modal('hide'); + flash_alert(JSON.parse(jqXHR.responseText).message, "danger", false); + } + }); + } + var items_table = $("#items_table").DataTable({ + dom: "<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-3 text-right'><'col-sm-12 col-md-3'f>>" + + "<'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" }, @@ -68,4 +93,18 @@ $(document).ready(function() { ] }); + new $.fn.dataTable.Buttons(items_table, { + buttons: [ + { + text: '<span class="oi oi-data-transfer-download" title="Export to excel file" aria-hidden="true"></span> Excel', + className: "btn-outline-secondary", + action: function ( e, dt, node, conf ) { + download_excel(); + } + } + ] + }); + + items_table.buttons().container().appendTo("#items_table_wrapper .col-md-3:eq(0)"); + }); diff --git a/app/tasks.py b/app/tasks.py index f85b4f28e7e09b9c5572ca99f380d0eb2940057a..2030b905d94efa95ae0d117941094ae82bec43b4 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -12,11 +12,13 @@ This module implements tasks to run. import time import traceback import tower_cli +from datetime import datetime from flask import current_app from flask_login import current_user from rq import Worker, get_current_job +from openpyxl import Workbook from .extensions import db -from . import models +from . import models, utils class TaskWorker(Worker): @@ -164,3 +166,30 @@ def launch_job_template(job_template, **kwargs): # Monitor the job until done result = resource.monitor(pk=result["id"]) return result + + +def generate_items_excel_file(): + """Export all inventory items to an excel file + + Return the name of the file + """ + job = get_current_job() + name = f"items-{datetime.utcnow().strftime('%Y%m%d_%H%M')}.xlsx" + full_path = utils.unique_filename(current_app.config["CSENTRY_STATIC_FILES"] / name) + # Instead of loading all items at once, we perform several smaller queries + pagination = models.Item.query.order_by(models.Item.created_at).paginate(1, 100) + wb = Workbook() + ws = wb.active + # Add header + # Note that we rely on the fact that dict keep their order in Python 3.6 + # (this is official in 3.7) + ws.append(list(pagination.items[0].to_dict().keys())) + while pagination.items: + for item in pagination.items: + ws.append([val for val in item.to_row_dict().values()]) + job.meta["progress"] = int(100 * pagination.page / pagination.pages) + current_app.logger.debug(f"progress: {job.meta['progress']}") + job.save_meta() + pagination = pagination.next() + wb.save(full_path) + return full_path.name diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html index 755e0f230710b190090afb7443580aec47547025..39d68af5d23f4e339be970161e1f77148d8545b8 100644 --- a/app/templates/_helpers.html +++ b/app/templates/_helpers.html @@ -179,3 +179,20 @@ <figcaption class="figure-caption text-center">{{ description }}</figcaption> </figure> {%- endmacro %} + +{% macro waiting_for(title, label) -%} + <div class="modal" tabindex="-1" role="dialog" id="{{ label }}"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h5 class="modal-title">{{ title }}</h5> + </div> + <div class="modal-body"> + <div class="progress"> + <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div> + </div> + </div> + </div> + </div> + </div> +{%- endmacro %} diff --git a/app/templates/base.html b/app/templates/base.html index 9e7533c6ef449c7bcfc94733a108fb0ba6dbe64a..fffb97962e0db2d6d34da70aa6a88eb852ed5a9e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -36,6 +36,7 @@ <a class="nav-item nav-link" href="{{ config['DOCUMENTATION_URL'] }}" target="_blank">Help</a> </div> <div class="navbar-nav"> + {% block navbar_right %}{% endblock %} {% if current_user.is_authenticated %} <div class="dropdown {{ is_active(path == "/user/profile") }}"> <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> @@ -47,7 +48,6 @@ </div> </div> {% endif %} - {% block navbar_right %}{% endblock %} </div> </div> </div> diff --git a/app/templates/inventory/items.html b/app/templates/inventory/items.html index 0940e5578425cc4cede52cfd618e0b648d58a94c..31f59bfcfd8731a4428cb0ebe9dc784f670f6529 100644 --- a/app/templates/inventory/items.html +++ b/app/templates/inventory/items.html @@ -1,9 +1,10 @@ {% extends "base-fluid.html" %} -{% from "_helpers.html" import is_active %} +{% from "_helpers.html" import is_active, waiting_for %} {% block title %}Items - CSEntry{% endblock %} {% block main %} + {{ waiting_for("Please wait while your file is being prepared...", "downloadExcelModal") }} {% set path = request.path %} <ul class="nav nav-tabs"> <li class="nav-item"> @@ -18,7 +19,7 @@ <br> {% block items_main %} - <table id="items_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%"> + <table id="items_table" class="table table-bordered table-hover table-sm" style="width:100%"> <thead> <tr> <th>Id</th> diff --git a/app/utils.py b/app/utils.py index 003a1cb1bf74abec59327a2d87e579c7f6b48a11..a70a9e1f99274b61b9997a6ccd80b1ec0ed5d707 100644 --- a/app/utils.py +++ b/app/utils.py @@ -16,7 +16,8 @@ import random import sqlalchemy as sa import dateutil.parser import yaml -from flask import current_app +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 @@ -248,3 +249,33 @@ def trigger_core_services_update(): current_app.logger.info(f"Launch new job to update core services: {job_template}") task = current_user.launch_task("trigger_core_services_update", **kwargs) return task + + +def redirect_to_job_status(job_id): + """ + The answer to a client request, leading it to regularly poll a job status. + + :param job_id: The id of the job started, which needs to be pulled by the client + :type job_id: rq.job_id + :return: HTTP response + """ + return jsonify({}), 202, {"Location": url_for("main.job_status", job_id=job_id)} + + +def unique_filename(filename): + """Return an unique filename + + :param filename: filename that should be unique + :returns: unique filename + """ + p = Path(filename) + if not p.exists(): + return filename + base = p.with_suffix("") + nb = 1 + while True: + unique = Path(f"{base}-{nb}{p.suffix}") + if not unique.exists(): + break + nb += 1 + return unique diff --git a/docs/_static/inventory.png b/docs/_static/inventory.png index f1e2e22d1d40e52ea59837bca873d499569534b8..356beb2b03cba7c021ca49216e97f70a4d1691d5 100644 Binary files a/docs/_static/inventory.png and b/docs/_static/inventory.png differ diff --git a/docs/inventory.rst b/docs/inventory.rst index 26d7737965de4ac72b756615eec8795b27fdba5e..dd3cd23f70b803becf57cb9fe4941ae8a61836c4 100644 --- a/docs/inventory.rst +++ b/docs/inventory.rst @@ -31,6 +31,9 @@ Note that the search is case-sensitive! .. image:: _static/inventory.png +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. + Clicking on the **ICS id** of an item will take you to the *View item* page. .. image:: _static/view_item.png diff --git a/requirements-to-freeze.txt b/requirements-to-freeze.txt index 5fea95bc6cdf8d04b740e0b57574978c00202568..7d087bb041c85a3eaaa52019a57e8b4c96750253 100644 --- a/requirements-to-freeze.txt +++ b/requirements-to-freeze.txt @@ -25,3 +25,4 @@ rq rq-dashboard sqlalchemy-citext sqlalchemy-continuum +openpyxl diff --git a/requirements.txt b/requirements.txt index 87512fb06a41ba853a9e93feefac5de935af6718..21a2a7bf853d4036b38f77d32f4bd0d14fa55e4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ certifi==2018.4.16 chardet==3.0.4 click==6.7 colorama==0.3.9 +et-xmlfile==1.0.1 Flask==1.0.2 Flask-Admin==1.5.1 Flask-Caching==1.4.0 @@ -21,10 +22,12 @@ Flask-SQLAlchemy==2.3.2 Flask-WTF==0.14.2 idna==2.7 itsdangerous==0.24 +jdcal==1.4 Jinja2==2.10 ldap3==2.5.1 Mako==1.0.7 MarkupSafe==1.0 +openpyxl==2.5.7 Pillow==5.2.0 psycopg2==2.7.5 pyasn1==0.4.4 diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..68542e37e826498e1187393316397e213fa08261 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" +tests.unit.test_utils +~~~~~~~~~~~~~~~~~~~~~ + +This module defines utils tests. + +:copyright: (c) 2018 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" +from pathlib import Path +from app import utils + + +class TestUniqueFilename: + def test_no_file(self, tmpdir): + p = tmpdir.join("test.xlsx") + assert utils.unique_filename(p) == Path(p) + + def test_one_not_related_file(self, tmpdir): + p = tmpdir.join("test.xlsx") + tmpdir.join("foo.xlsx").write("foo") + assert utils.unique_filename(p) == Path(p) + + def test_one_file(self, tmpdir): + p = tmpdir.join("test.xlsx") + p.write("Hello") + assert utils.unique_filename(p) == Path(tmpdir.join("test-1.xlsx")) + + def test_several_files(self, tmpdir): + p = tmpdir.join("test.xlsx") + p.write("Hello") + tmpdir.join("test-1.xlsx").write("foo") + tmpdir.join("test-2.xlsx").write("foo") + assert utils.unique_filename(p) == Path(tmpdir.join("test-3.xlsx")) + + def test_first_available(self, tmpdir): + p = tmpdir.join("test.xlsx") + p.write("Hello") + tmpdir.join("test-2.xlsx").write("foo") + assert utils.unique_filename(p) == Path(tmpdir.join("test-1.xlsx")) + + def test_no_extension(self, tmpdir): + p = tmpdir.join("test") + p.write("Hello") + assert utils.unique_filename(p) == Path(tmpdir.join("test-1"))