diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5becab3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +.eggs +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml +.testrepository +.venv + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Complexity +output/*.html +output/*/index.html + +# Sphinx +doc/build + +# pbr generates these +AUTHORS +ChangeLog + +# Editors +*~ +.*.swp +.*sw? + + +# Horizon related +*.lock +watcher_dashboard/test/.secret_key_store diff --git a/HACKING.rst b/HACKING.rst new file mode 100644 index 0000000..2b41b20 --- /dev/null +++ b/HACKING.rst @@ -0,0 +1,60 @@ +Contributing +============ + +The code repository is located at `OpenStack `__. +Please go there if you want to check it out: + + git clone https://github.com/openstack/watcher-dashboard.git + +The list of bugs and blueprints is on Launchpad: + +``__ + +We use OpenStack's Gerrit for the code contributions: + +``__ + +and we follow the `OpenStack Gerrit Workflow `__. + +If you're interested in the code, here are some key places to start: + +* `watcher_dashboard/api.py `_ + - This file contains all the API calls made to the Watcher API + (through python-watcherclient). +* `watcher_dashboard/infra_optim `_ + - The Watcher Dashboard code is contained within this directory. + +Running tests +============= + +There are several ways to run tests for watcher-dashboard. + +Using ``tox``: + + This is the easiest way to run tests. When run, tox installs dependencies, + prepares the virtual python environment, then runs test commands. The gate + tests in gerrit usually also use tox to run tests. For avaliable tox + environments, see ``tox.ini``. + +By running ``run_tests.sh``: + + Tests can also be run using the ``run_tests.sh`` script, to see available + options, run it with the ``--help`` option. It handles preparing the + virtual environment and executing tests, but in contrast with tox, it does + not install all dependencies, e.g. ``jshint`` must be installed before + running the jshint testcase. + +Manual tests: + + To manually check watcher-dashboard, it is possible to run a development server + for watcher-dashboard by running ``run_tests.sh --runserver``. + + To run the server with the settings used by the test environment: + ``run_tests.sh --runserver 0.0.0.0:8000`` + +OpenStack Style Commandments +============================ + +- Step 1: Read http://www.python.org/dev/peps/pep-0008/ +- Step 2: Read http://www.python.org/dev/peps/pep-0008/ again +- Step 3: Read https://github.com/openstack-dev/hacking/blob/master/HACKING.rst diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..68c771a --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..54a0e99 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include AUTHORS +include ChangeLog +exclude .gitignore +exclude .gitreview + +global-exclude *.pyc +recursive-include watcher_dashboard/templates * diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f2c16dd --- /dev/null +++ b/README.rst @@ -0,0 +1,126 @@ +OpenStack Dashboard plugin for Watcher project +============================================== + +Installation +------------ + + +First off, create a virtual environment and install the Horizon dependencies:: + + $ git clone https://github.com/openstack/horizon + $ cd horizon + $ python tools/install_venv.py + +We will refer to the folder you are now in as ````. +If you want more details on how to install Horizon, you can have a look at the +`Horizon documentation`_, especially their `quickstart tutorial`_. + +Then, you need to install Watcher Dashboard on the server running Horizon. +To do so, you can issue the following commands:: + + $ git clone git://git.openstack.org/openstack/watcher-dashboard + $ cd watcher-dashboard + $ pip install -e . + +We will refer to the folder you are now in as ````. + +The next step is now to register the Watcher Dashboard plugins against your +Horizon. To do so, you can execute the ``tools/register_plugin.sh``:: + + $ cd + $ ./tools/register_plugin.sh . + +This script will then create the needed symlinks within Horizon so that it can +load the Watcher plugin when it starts. + +If you wish to have Horizon running being an Apache server, do not forget to +start the service via the following command:: + + $ sudo service apache2 restart + +For more details on how to configure Horizon for a production environment, you +can refer to their online `installation guide`_. + +.. _Horizon documentation: http://docs.openstack.org/developer/horizon +.. _quickstart tutorial: http://docs.openstack.org/developer/horizon/quickstart.html +.. _installation guide: http://docs.openstack.org/developer/horizon/topics/install.html + + +DevStack setup +-------------- + +Add the following to your DevStack ``local.conf`` file + +:: + + enable_plugin watcher-dashboard git://git.openstack.org/openstack/watcher-dashboard + + +Unit testing +------------ + +First of all, you have to create an environment to run your tests in. This step +is actually part of the ``run_tests.sh`` script which creates and maintains a +clean virtual environment. + +Here below is the basic command to run Watcher Dashboard tests:: + + $ ./run_tests.sh + +The first time you will issue the command above, you will be asked if you want +to create a virtual environment. So unless you have installed everything +manually (in which case you should use the ``-N`` flag), you need to accept + + +Integration testing +------------------- + +Before being able to run integration tests, you need to have a Horizon server +running with Watcher Dashboard plugin configured. To do so, you can run a test +server using the following command:: + + $ ./run_tests.sh --runserver 0.0.0.0:8000 + +By default, integration tests expect to find a running Horizon server at +``http://localhost:8000/`` but this can be customized by editing the +``watcher_dashboard/test/integration_tests/horizon.conf`` configuration file. +Likewise, this Horizon will be looking, by default, for a Keystone backend at +``http://localhost:5000/v2.0``. So in order to customize its location, you will +have to edit ``watcher_dashboard/test/settings.py`` by updating the +``OPENSTACK_KEYSTONE_URL`` variable. + +To run integration tests:: + + $ ./run_tests.sh --integration + +You can use PhantomJS as a headless browser to execute your integration tests. +On an Ubuntu distribution you can install it via the following command:: + + $ sudo apt-get install phantomjs + +Then you can run your integration tests like this:: + + $ ./run_tests.sh --integration --selenium-headless + +Please note that these commands are also available via ``tox``. + +.. note:: + + As of the Mitaka release, the dashboard for watcher is now maintained + outside of the Horizon codebase, in this repository. + +Links +----- + +Watcher project: https://git.openstack.org/openstack/watcher + +Watcher at wiki.openstack.org: https://wiki.openstack.org/wiki/Watcher + +Launchpad project: https://launchpad.net/watcher + +Join us on IRC (Internet Relay Chat):: + + Network: Freenode (irc.freenode.net/watcher) + Channel: #openstack-watcher + +Or send an email to openstack-dev@lists.openstack.org. diff --git a/babel-django.cfg b/babel-django.cfg new file mode 100644 index 0000000..c241396 --- /dev/null +++ b/babel-django.cfg @@ -0,0 +1,5 @@ +[extractors] +django = django_babel.extract:extract_django + +[python: watcher_dashboard/**.py] +[django: watcher_dashboard/**/templates/**.html] diff --git a/babel-djangojs.cfg b/babel-djangojs.cfg new file mode 100644 index 0000000..ade8d91 --- /dev/null +++ b/babel-djangojs.cfg @@ -0,0 +1,14 @@ +[extractors] +# We use a custom extractor to find translatable strings in AngularJS +# templates. The extractor is included in horizon.utils for now. +# See http://babel.pocoo.org/docs/messages/#referencing-extraction-methods for +# details on how this works. +angular = horizon.utils.babel_extract_angular:extract_angular + +[javascript: watcher_dashboard/**.js] + +# We need to look into all static folders for HTML files. +# The **/static ensures that we also search within +# /openstack_dashboard/dashboards/XYZ/static which will ensure +# that plugins are also translated. +[angular: watcher_dashboard/**/static/**.html] diff --git a/devstack/plugin.sh b/devstack/plugin.sh new file mode 100644 index 0000000..505ee92 --- /dev/null +++ b/devstack/plugin.sh @@ -0,0 +1,54 @@ +# plugin.sh - DevStack plugin.sh dispatch script watcher-dashboard + +WATCHER_DASHBOARD_DIR=$(cd $(dirname $BASH_SOURCE)/.. && pwd) + +function install_watcher_dashboard { + setup_develop ${WATCHER_DASHBOARD_DIR} +} + +function configure_watcher_dashboard { + cp -a ${WATCHER_DASHBOARD_DIR}/watcher_dashboard/enabled/* ${DEST}/horizon/openstack_dashboard/local/enabled/ +} + +function init_watcher_dashboard { + # Setup alias for django-admin which could be different depending on distro + python ${DEST}/horizon/manage.py collectstatic --noinput + python ${DEST}/horizon//manage.py compress --force +} + +# check for service enabled +if is_service_enabled watcher-dashboard; then + + if [[ "$1" == "stack" && "$2" == "pre-install" ]]; then + # Set up system services + # no-op + : + + elif [[ "$1" == "stack" && "$2" == "install" ]]; then + # Perform installation of service source + echo_summary "Installing Watcher Dashboard" + install_watcher_dashboard + + elif [[ "$1" == "stack" && "$2" == "post-config" ]]; then + # Configure after the other layer 1 and 2 services have been configured + echo_summary "Configurng Watcher Dashboard" + configure_watcher_dashboard + init_watcher_dashboard + + elif [[ "$1" == "stack" && "$2" == "extra" ]]; then + # no-op + : + fi + + if [[ "$1" == "unstack" ]]; then + rm -f ${DEST}/horizon/openstack_dashboard/local/enabled/_310* + + fi + + if [[ "$1" == "clean" ]]; then + # Remove state and transient data + # Remember clean.sh first calls unstack.sh + # no-op + : + fi +fi diff --git a/devstack/settings b/devstack/settings new file mode 100644 index 0000000..182aee9 --- /dev/null +++ b/devstack/settings @@ -0,0 +1,2 @@ +# settings file for watcher-dashboard plugin +enable_service watcher-dashboard diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100755 index 0000000..2b791c6 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# -- General configuration ---------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'oslosphinx', +] + +wsme_protocols = ['restjson'] + + +# autodoc generation is a bit aggressive and a nuisance when doing heavy +# text edit cycles. +# execute "export SPHINX_DEBUG=1" in your terminal to disable + +# The suffix of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Watcher' +copyright = u'OpenStack Foundation' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +# The full version, including alpha/beta/rc tags. +# release = +# The short X.Y version. +# version = watcher_version.version_info.version_string() + +# A list of ignored prefixes for module index sorting. +modindex_common_prefix = ['watcher.'] + +exclude_patterns = [ + # The man directory includes some snippet files that are included + # in other documents during the build but that should not be + # included in the toctree themselves, so tell Sphinx to ignore + # them when scanning for input files. + 'man/footer.rst', + 'man/general-options.rst', +] + +# If true, '()' will be appended to :func: etc. cross-reference text. +add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +add_module_names = True + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# -- Options for man page output -------------------------------------------- + +# Grouping the document tree for man pages. +# List of tuples 'sourcefile', 'target', u'title', u'Authors name', 'manual' + +man_pages = [] + +# -- Options for HTML output -------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +# html_theme_path = ["."] +# html_theme = '_theme' +# html_static_path = ['static'] +html_theme_options = {'incubating': True} + +# Output file base name for HTML help builder. +htmlhelp_basename = '%sdoc' % project + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass +# [howto/manual]). +latex_documents = [ + ('index', + '%s.tex' % project, + u'%s Documentation' % project, + u'OpenStack Foundation', 'manual'), +] + +# Example configuration for intersphinx: refer to the Python standard library. +# intersphinx_mapping = {'http://docs.python.org/': None} diff --git a/doc/source/deploy/installation.rst b/doc/source/deploy/installation.rst new file mode 100644 index 0000000..e4a3ad5 --- /dev/null +++ b/doc/source/deploy/installation.rst @@ -0,0 +1 @@ +.. include:: ../../../README.rst diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..00b9c38 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,53 @@ +.. + Except where otherwise noted, this document is licensed under Creative + Commons Attribution 3.0 License. You can view the license at: + + https://creativecommons.org/licenses/by/3.0/ + +========================================== +Welcome to Watcher Dashboard documentation +========================================== + +OpenStack Watcher provides a flexible and scalable resource optimization +service for multi-tenant OpenStack-based clouds. +Watcher provides a complete optimization loop—including everything from a +metrics receiver, complex event processor and profiler, optimization processor +and an action plan applier. This provides a robust framework to realize a wide +range of cloud optimization goals, including the reduction of data center +operating costs, increased system performance via intelligent virtual machine +migration, increased energy efficiency—and more! + +Watcher project consists of several source code repositories: + +* `watcher`_ - is the main repository. It contains code for Watcher API server, + Watcher Decision Engine and Watcher Applier. +* `python-watcherclient`_ - Client library and CLI client for Watcher. +* `watcher-dashboard`_ - Watcher Horizon plugin. + +The documentation provided here is continually kept up-to-date based +on the latest code, and may not represent the state of the project at any +specific prior release. + +.. _watcher: https://git.openstack.org/cgit/openstack/watcher/ +.. _python-watcherclient: https://git.openstack.org/cgit/openstack/python-watcherclient/ +.. _watcher-dashboard: https://git.openstack.org/cgit/openstack/watcher-dashboard/ + + +Developer Guide +=============== + +Introduction +------------ + +.. toctree:: + :maxdepth: 1 + + deploy/installation + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..cda0172 --- /dev/null +++ b/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import sys + +from django.core.management import execute_from_command_line + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", + "watcher_dashboard.test.settings") + execute_from_command_line(sys.argv) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..24e2a94 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration + +pbr>=1.8 +# Horizon Core Requirements +Django>=1.8,<1.9 # BSD +django_compressor>=1.4 # MIT +django_openstack_auth>=2.0.0 # Apache-2.0 +python-keystoneclient>=1.6.0,!=1.8.0,!=2.1.0 # Apache-2.0 +pytz>=2013.6 # MIT + +# Watcher-specific requirements +python-watcherclient diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..4a0a1b9 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,617 @@ +#!/bin/bash + +set -o errexit + +function usage { + echo "Usage: $0 [OPTION]..." + echo "Run Horizon's test suite(s)" + echo "" + echo " -V, --virtual-env Always use virtualenv. Install automatically" + echo " if not present" + echo " -N, --no-virtual-env Don't use virtualenv. Run tests in local" + echo " environment" + echo " -c, --coverage Generate reports using Coverage" + echo " -f, --force Force a clean re-build of the virtual" + echo " environment. Useful when dependencies have" + echo " been added." + echo " -m, --manage Run a Django management command." + echo " --makemessages Create/Update English translation files." + echo " --compilemessages Compile all translation files." + echo " --check-only Do not update translation files (--makemessages only)." + echo " --pseudo Pseudo translate a language." + echo " -p, --pep8 Just run pep8" + echo " -8, --pep8-changed []" + echo " Just run PEP8 and HACKING compliance check" + echo " on files changed since HEAD~1 (or )" + echo " -P, --no-pep8 Don't run pep8 by default" + echo " -t, --tabs Check for tab characters in files." + echo " -y, --pylint Just run pylint" + echo " -e, --eslint Just run eslint" + echo " -k, --karma Just run karma" + echo " -q, --quiet Run non-interactively. (Relatively) quiet." + echo " Implies -V if -N is not set." + echo " --only-selenium Run only the Selenium unit tests" + echo " --with-selenium Run unit tests including Selenium tests" + echo " --selenium-headless Run Selenium tests headless" + echo " --selenium-phantomjs Run Selenium tests using phantomjs (headless)" + echo " --integration Run the integration tests (requires a running " + echo " OpenStack environment)" + echo " --runserver Run the Django development server for" + echo " openstack_dashboard in the virtual" + echo " environment." + echo " --docs Just build the documentation" + echo " --backup-environment Make a backup of the environment on exit" + echo " --restore-environment Restore the environment before running" + echo " --destroy-environment Destroy the environment and exit" + echo " -h, --help Print this usage message" + echo "" + echo "Note: with no options specified, the script will try to run the tests in" + echo " a virtual environment, If no virtualenv is found, the script will ask" + echo " if you would like to create one. If you prefer to run tests NOT in a" + echo " virtual environment, simply pass the -N option." + exit +} + +# DEFAULTS FOR RUN_TESTS.SH +# +root=`pwd -P` +venv=$root/.venv +venv_env_version=$venv/environments +with_venv=tools/with_venv.sh +included_dirs="watcher_dashboard" + +always_venv=0 +backup_env=0 +command_wrapper="" +destroy=0 +force=0 +just_pep8=0 +just_pep8_changed=0 +no_pep8=0 +just_pylint=0 +just_docs=0 +just_tabs=0 +just_eslint=0 +just_karma=0 +never_venv=0 +quiet=0 +restore_env=0 +runserver=0 +only_selenium=0 +with_selenium=0 +selenium_headless=0 +selenium_phantomjs=0 +integration=0 +testopts="" +testargs="" +with_coverage=0 +makemessages=0 +compilemessages=0 +check_only=0 +pseudo=0 +manage=0 + +# Jenkins sets a "JOB_NAME" variable, if it's not set, we'll make it "default" +[ "$JOB_NAME" ] || JOB_NAME="default" + +function process_option { + # If running manage command, treat the rest of options as arguments. + if [ $manage -eq 1 ]; then + testargs="$testargs $1" + return 0 + fi + + case "$1" in + -h|--help) usage;; + -V|--virtual-env) always_venv=1; never_venv=0;; + -N|--no-virtual-env) always_venv=0; never_venv=1;; + -p|--pep8) just_pep8=1;; + -8|--pep8-changed) just_pep8_changed=1;; + -P|--no-pep8) no_pep8=1;; + -y|--pylint) just_pylint=1;; + -e|--eslint) just_eslint=1;; + -k|--karma) just_karma=1;; + -f|--force) force=1;; + -t|--tabs) just_tabs=1;; + -q|--quiet) quiet=1;; + -c|--coverage) with_coverage=1;; + -m|--manage) manage=1;; + --makemessages) makemessages=1;; + --compilemessages) compilemessages=1;; + --check-only) check_only=1;; + --pseudo) pseudo=1;; + --only-selenium) only_selenium=1;; + --with-selenium) with_selenium=1;; + --selenium-headless) selenium_headless=1;; + --selenium-phantomjs) selenium_phantomjs=1;; + --integration) integration=1;; + --docs) just_docs=1;; + --runserver) runserver=1;; + --backup-environment) backup_env=1;; + --restore-environment) restore_env=1;; + --destroy-environment) destroy=1;; + -*) testopts="$testopts $1";; + *) testargs="$testargs $1" + esac +} + +function run_management_command { + ${command_wrapper} python $root/manage.py $testopts $testargs +} + +function run_server { + echo "Starting Django development server..." + ${command_wrapper} python $root/manage.py runserver $testopts $testargs + echo "Server stopped." +} + +function run_pylint { + echo "Running pylint ..." + PYTHONPATH=$root ${command_wrapper} pylint --rcfile=.pylintrc -f parseable $included_dirs > pylint.txt || true + CODE=$? + grep Global -A2 pylint.txt + if [ $CODE -lt 32 ]; then + echo "Completed successfully." + exit 0 + else + echo "Completed with problems." + exit $CODE + fi +} + +function run_eslint { + echo "Running eslint ..." + if [ "`which npm`" == '' ] ; then + echo "npm is not present; please install, e.g. sudo apt-get install npm" + else + npm install + npm run lint + fi +} + +function run_karma { + echo "Running karma ..." + npm install + npm run test +} + +function warn_on_flake8_without_venv { + set +o errexit + ${command_wrapper} python -c "import hacking" 2>/dev/null + no_hacking=$? + set -o errexit + if [ $never_venv -eq 1 -a $no_hacking -eq 1 ]; then + echo "**WARNING**:" >&2 + echo "OpenStack hacking is not installed on your host. Its detection will be missed." >&2 + echo "Please install or use virtual env if you need OpenStack hacking detection." >&2 + fi +} + +function run_pep8 { + echo "Running flake8 ..." + warn_on_flake8_without_venv + DJANGO_SETTINGS_MODULE=watcher_dashboard.test.settings ${command_wrapper} flake8 +} + +function run_pep8_changed { + # NOTE(gilliard) We want use flake8 to check the entirety of every file that has + # a change in it. Unfortunately the --filenames argument to flake8 only accepts + # file *names* and there are no files named (eg) "nova/compute/manager.py". The + # --diff argument behaves surprisingly as well, because although you feed it a + # diff, it actually checks the file on disk anyway. + local base_commit=${testargs:-HEAD~1} + files=$(git diff --name-only $base_commit | tr '\n' ' ') + echo "Running flake8 on ${files}" + warn_on_flake8_without_venv + diff -u --from-file /dev/null ${files} | DJANGO_SETTINGS_MODULE=watcher_dashboard.test.settings ${command_wrapper} flake8 --diff + exit +} + +function run_sphinx { + echo "Building sphinx..." + DJANGO_SETTINGS_MODULE=watcher_dashboard.test.settings ${command_wrapper} python setup.py build_sphinx + echo "Build complete." +} + +function tab_check { + TAB_VIOLATIONS=`find $included_dirs -type f -regex ".*\.\(css\|js\|py\|html\)" -print0 | xargs -0 awk '/\t/' | wc -l` + if [ $TAB_VIOLATIONS -gt 0 ]; then + echo "TABS! $TAB_VIOLATIONS of them! Oh no!" + HORIZON_FILES=`find $included_dirs -type f -regex ".*\.\(css\|js\|py|\html\)"` + for TABBED_FILE in $HORIZON_FILES + do + TAB_COUNT=`awk '/\t/' $TABBED_FILE | wc -l` + if [ $TAB_COUNT -gt 0 ]; then + echo "$TABBED_FILE: $TAB_COUNT" + fi + done + fi + return $TAB_VIOLATIONS; +} + +function destroy_venv { + echo "Cleaning environment..." + echo "Removing virtualenv..." + rm -rf $venv + echo "Virtualenv removed." +} + +function environment_check { + echo "Checking environment." + if [ -f $venv_env_version ]; then + set +o errexit + cat requirements.txt test-requirements.txt | cmp $venv_env_version - > /dev/null + local env_check_result=$? + set -o errexit + if [ $env_check_result -eq 0 ]; then + # If the environment exists and is up-to-date then set our variables + command_wrapper="${root}/${with_venv}" + echo "Environment is up to date." + return 0 + fi + fi + + if [ $always_venv -eq 1 ]; then + install_venv + else + if [ ! -e ${venv} ]; then + echo -e "Environment not found. Install? (Y/n) \c" + else + echo -e "Your environment appears to be out of date. Update? (Y/n) \c" + fi + read update_env + if [ "x$update_env" = "xY" -o "x$update_env" = "x" -o "x$update_env" = "xy" ]; then + install_venv + else + # Set our command wrapper anyway. + command_wrapper="${root}/${with_venv}" + fi + fi +} + +function sanity_check { + # Anything that should be determined prior to running the tests, server, etc. + # Don't sanity-check anything environment-related in -N flag is set + if [ $never_venv -eq 0 ]; then + if [ ! -e ${venv} ]; then + echo "Virtualenv not found at $venv. Did install_venv.py succeed?" + exit 1 + fi + fi + # Remove .pyc files. This is sanity checking because they can linger + # after old files are deleted. + find . -name "*.pyc" -exec rm -rf {} \; +} + +function backup_environment { + if [ $backup_env -eq 1 ]; then + echo "Backing up environment \"$JOB_NAME\"..." + if [ ! -e ${venv} ]; then + echo "Environment not installed. Cannot back up." + return 0 + fi + if [ -d /tmp/.horizon_environment/$JOB_NAME ]; then + mv /tmp/.horizon_environment/$JOB_NAME /tmp/.horizon_environment/$JOB_NAME.old + rm -rf /tmp/.horizon_environment/$JOB_NAME + fi + mkdir -p /tmp/.horizon_environment/$JOB_NAME + cp -r $venv /tmp/.horizon_environment/$JOB_NAME/ + # Remove the backup now that we've completed successfully + rm -rf /tmp/.horizon_environment/$JOB_NAME.old + echo "Backup completed" + fi +} + +function restore_environment { + if [ $restore_env -eq 1 ]; then + echo "Restoring environment from backup..." + if [ ! -d /tmp/.horizon_environment/$JOB_NAME ]; then + echo "No backup to restore from." + return 0 + fi + + cp -r /tmp/.horizon_environment/$JOB_NAME/.venv ./ || true + echo "Environment restored successfully." + fi +} + +function install_venv { + # Install with install_venv.py + export PIP_DOWNLOAD_CACHE=${PIP_DOWNLOAD_CACHE-/tmp/.pip_download_cache} + export PIP_USE_MIRRORS=true + if [ $quiet -eq 1 ]; then + export PIP_NO_INPUT=true + fi + echo "Fetching new src packages..." + rm -rf $venv/src + python tools/install_venv.py + command_wrapper="$root/${with_venv}" + # Make sure it worked and record the environment version + sanity_check + chmod -R 754 $venv + cat requirements.txt test-requirements.txt > $venv_env_version +} + +function run_tests { + sanity_check + + if [ $with_selenium -eq 1 ]; then + export WITH_SELENIUM=1 + elif [ $only_selenium -eq 1 ]; then + export WITH_SELENIUM=1 + export SKIP_UNITTESTS=1 + fi + + if [ $with_selenium -eq 0 -a $integration -eq 0 ]; then + testopts="$testopts --exclude=watcher_dashboard/test/integration_tests/" + fi + + if [ $selenium_headless -eq 1 ]; then + export SELENIUM_HEADLESS=1 + fi + + if [ $selenium_phantomjs -eq 1 ]; then + export SELENIUM_PHANTOMJS=1 + fi + + if [ -z "$testargs" ]; then + run_tests_all + else + run_tests_subset + fi +} + +function run_tests_subset { + project=`echo $testargs | awk -F. '{print $1}'` + ${command_wrapper} python $root/manage.py test --settings=$project.test.settings $testopts $testargs +} + +function run_tests_all { + echo "Running Horizon application tests" + export NOSE_XUNIT_FILE=horizon/nosetests.xml + if [ "$NOSE_WITH_HTML_OUTPUT" = '1' ]; then + export NOSE_HTML_OUT_FILE='horizon_nose_results.html' + fi + if [ $with_coverage -eq 1 ]; then + ${command_wrapper} python -m coverage.__main__ erase + coverage_run="python -m coverage.__main__ run -p" + fi + ${command_wrapper} ${coverage_run} $root/manage.py test horizon --settings=horizon.test.settings $testopts + # get results of the Horizon tests + HORIZON_RESULT=$? + + echo "Running openstack_dashboard tests" + export NOSE_XUNIT_FILE=openstack_dashboard/nosetests.xml + if [ "$NOSE_WITH_HTML_OUTPUT" = '1' ]; then + export NOSE_HTML_OUT_FILE='dashboard_nose_results.html' + fi + ${command_wrapper} ${coverage_run} $root/manage.py test openstack_dashboard --settings=watcher_dashboard.test.settings $testopts + # get results of the openstack_dashboard tests + DASHBOARD_RESULT=$? + + if [ $with_coverage -eq 1 ]; then + echo "Generating coverage reports" + ${command_wrapper} python -m coverage.__main__ combine + ${command_wrapper} python -m coverage.__main__ xml -i --include="horizon/*,openstack_dashboard/*" --omit='/usr*,setup.py,*egg*,.venv/*' + ${command_wrapper} python -m coverage.__main__ html -i --include="horizon/*,openstack_dashboard/*" --omit='/usr*,setup.py,*egg*,.venv/*' -d reports + fi + # Remove the leftover coverage files from the -p flag earlier. + rm -f .coverage.* + + PEP8_RESULT=0 + if [ $no_pep8 -eq 0 ] && [ $only_selenium -eq 0 ]; then + run_pep8 + PEP8_RESULT=$? + fi + + TEST_RESULT=$(($HORIZON_RESULT || $DASHBOARD_RESULT || $PEP8_RESULT)) + if [ $TEST_RESULT -eq 0 ]; then + echo "Tests completed successfully." + else + echo "Tests failed." + fi + exit $TEST_RESULT +} + +function run_integration_tests { + export INTEGRATION_TESTS=1 + + if [ $selenium_headless -eq 1 ]; then + export SELENIUM_HEADLESS=1 + fi + + if [ $selenium_phantomjs -eq 1 ]; then + export SELENIUM_PHANTOMJS=1 + fi + + echo "Running Watcher Horizon integration tests..." + if [ -z "$testargs" ]; then + ${command_wrapper} nosetests watcher_dashboard/test/integration_tests/tests + else + ${command_wrapper} nosetests $testargs + fi + exit 0 +} + +function babel_extract { + DOMAIN=$1 + KEYWORDS="-k gettext_noop -k gettext_lazy -k ngettext_lazy:1,2" + KEYWORDS+=" -k ugettext_noop -k ugettext_lazy -k ungettext_lazy:1,2" + KEYWORDS+=" -k npgettext:1c,2,3 -k pgettext_lazy:1c,2 -k npgettext_lazy:1c,2,3" + + ${command_wrapper} pybabel extract -F ../babel-${DOMAIN}.cfg -o locale/${DOMAIN}.pot $KEYWORDS . +} + +function run_makemessages { + + echo -n "horizon: " + cd horizon + babel_extract django + HORIZON_PY_RESULT=$? + + echo -n "horizon javascript: " + babel_extract djangojs + HORIZON_JS_RESULT=$? + + echo -n "openstack_dashboard: " + cd ../openstack_dashboard + babel_extract django + DASHBOARD_RESULT=$? + + echo -n "openstack_dashboard javascript: " + babel_extract djangojs + DASHBOARD_JS_RESULT=$? + + cd .. + if [ $check_only -eq 1 ]; then + git checkout -- horizon/locale/django*.pot + git checkout -- openstack_dashboard/locale/django*.pot + fi + + exit $(($HORIZON_PY_RESULT || $HORIZON_JS_RESULT || $DASHBOARD_RESULT || $DASHBOARD_JS_RESULT)) +} + +function run_compilemessages { + cd horizon + ${command_wrapper} $root/manage.py compilemessages + HORIZON_PY_RESULT=$? + cd ../openstack_dashboard + ${command_wrapper} $root/manage.py compilemessages + DASHBOARD_RESULT=$? + exit $(($HORIZON_PY_RESULT || $DASHBOARD_RESULT)) +} + +function run_pseudo { + for lang in $testargs + # Use English pot file as the source file/pot file just like real Horizon translations + do + ${command_wrapper} $root/tools/pseudo.py openstack_dashboard/locale/django.pot openstack_dashboard/locale/$lang/LC_MESSAGES/django.po $lang + ${command_wrapper} $root/tools/pseudo.py openstack_dashboard/locale/djangojs.pot openstack_dashboard/locale/$lang/LC_MESSAGES/djangojs.po $lang + ${command_wrapper} $root/tools/pseudo.py horizon/locale/django.pot horizon/locale/$lang/LC_MESSAGES/django.po $lang + ${command_wrapper} $root/tools/pseudo.py horizon/locale/djangojs.pot horizon/locale/$lang/LC_MESSAGES/djangojs.po $lang + done + exit $? +} + + +# ---------PREPARE THE ENVIRONMENT------------ # + +# PROCESS ARGUMENTS, OVERRIDE DEFAULTS +for arg in "$@"; do + process_option $arg +done + +if [ $quiet -eq 1 ] && [ $never_venv -eq 0 ] && [ $always_venv -eq 0 ] +then + always_venv=1 +fi + +# If destroy is set, just blow it away and exit. +if [ $destroy -eq 1 ]; then + destroy_venv + exit 0 +fi + +# Ignore all of this if the -N flag was set +if [ $never_venv -eq 0 ]; then + + # Restore previous environment if desired + if [ $restore_env -eq 1 ]; then + restore_environment + fi + + # Remove the virtual environment if --force used + if [ $force -eq 1 ]; then + destroy_venv + fi + + # Then check if it's up-to-date + environment_check + + # Create a backup of the up-to-date environment if desired + if [ $backup_env -eq 1 ]; then + backup_environment + fi +fi + +# ---------EXERCISE THE CODE------------ # + +# Run management commands +if [ $manage -eq 1 ]; then + run_management_command + exit $? +fi + +# Build the docs +if [ $just_docs -eq 1 ]; then + run_sphinx + exit $? +fi + +# Update translation files +if [ $makemessages -eq 1 ]; then + run_makemessages + exit $? +fi + +# Compile translation files +if [ $compilemessages -eq 1 ]; then + run_compilemessages + exit $? +fi + +# Generate Pseudo translation +if [ $pseudo -eq 1 ]; then + run_pseudo + exit $? +fi + +# PEP8 +if [ $just_pep8 -eq 1 ]; then + run_pep8 + exit $? +fi + +if [ $just_pep8_changed -eq 1 ]; then + run_pep8_changed + exit $? +fi + +# Pylint +if [ $just_pylint -eq 1 ]; then + run_pylint + exit $? +fi + +# ESLint +if [ $just_eslint -eq 1 ]; then + run_eslint + exit $? +fi + +# Karma +if [ $just_karma -eq 1 ]; then + run_karma + exit $? +fi + +# Tab checker +if [ $just_tabs -eq 1 ]; then + tab_check + exit $? +fi + +# Integration tests +if [ $integration -eq 1 ]; then + run_integration_tests + exit $? +fi + +# Django development server +if [ $runserver -eq 1 ]; then + run_server + exit $? +fi + +# Full test suite +run_tests || exit diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..764d792 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,40 @@ +[metadata] +name = watcher-dashboard +summary = Watcher Management Dashboard +description-file = + README.rst +author = OpenStack +author-email = openstack-dev@lists.openstack.org +home-page = http://www.openstack.org/ +classifier = + Development Status :: 5 - Production/Stable + Environment :: OpenStack + Framework :: Django + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 2.6 + Topic :: Internet :: WWW/HTTP + +[global] +setup-hooks = + pbr.hooks.setup_hook + +[files] +packages = + watcher_dashboard + +[build_sphinx] +all_files = 1 +build-dir = doc/build +source-dir = doc/source + +[nosetests] +verbosity=2 +detailed-errors=1 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..c398fd2 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ + +# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT +import setuptools + +# In python < 2.7.4, a lazy loading of package `pbr` will break +# setuptools if some other modules registered functions in `atexit`. +# solution from: http://bugs.python.org/issue15881#msg170215 +try: + import multiprocessing # noqa +except ImportError: + pass + +setuptools.setup( + setup_requires=['pbr>=1.8'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..754fc46 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,26 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +# Hacking already pins down pep8, pyflakes and flake8 + +-e git://github.com/openstack/horizon.git#egg=horizon + +hacking>=0.10.2,<0.11 # Apache-2.0 +coverage>=3.6 # Apache-2.0 +ddt>=1.0.1 # MIT +django-nose>=1.2 # BSD +discover # BSD +mock>=1.2 # BSD +mox3>=0.7.0 # Apache-2.0 +nose-exclude # LGPL +python-subunit>=0.0.18 # Apache-2.0/BSD +selenium!=2.49,!=2.50 # Apache-2.0 +testrepository>=0.0.18 # Apache-2.0/BSD +testscenarios>=0.4 # Apache-2.0/BSD +testtools>=1.4.0 # MIT +# This also needs xvfb library installed on your OS +xvfbwrapper>=0.1.3,!=0.2.8 #license: MIT + +# Doc requirements +oslosphinx>=2.5.0 # Apache-2.0 +sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3 diff --git a/tools/install_venv.py b/tools/install_venv.py new file mode 100644 index 0000000..e96521e --- /dev/null +++ b/tools/install_venv.py @@ -0,0 +1,71 @@ +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2010 OpenStack Foundation +# Copyright 2013 IBM Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import sys + +import install_venv_common as install_venv # noqa + + +def print_help(venv, root): + help = """ + OpenStack development environment setup is complete. + + OpenStack development uses virtualenv to track and manage Python + dependencies while in development and testing. + + To activate the OpenStack virtualenv for the extent of your current shell + session you can run: + + $ source %s/bin/activate + + Or, if you prefer, you can run commands in the virtualenv on a case by case + basis by running: + + $ %s/tools/with_venv.sh + + Also, make test will automatically use the virtualenv. + """ + print(help % (venv, root)) + + +def main(argv): + root = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + if os.environ.get('tools_path'): + root = os.environ['tools_path'] + venv = os.path.join(root, '.venv') + if os.environ.get('venv'): + venv = os.environ['venv'] + + pip_requires = os.path.join(root, 'requirements.txt') + test_requires = os.path.join(root, 'test-requirements.txt') + py_version = "python%s.%s" % (sys.version_info[0], sys.version_info[1]) + project = 'OpenStack' + install = install_venv.InstallVenv(root, venv, pip_requires, test_requires, + py_version, project) + options = install.parse_args(argv) + install.check_python_version() + install.check_dependencies() + install.create_virtualenv(no_site_packages=options.no_site_packages) + install.install_dependencies() + print_help(venv, root) + +if __name__ == '__main__': + main(sys.argv) diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py new file mode 100644 index 0000000..46822e3 --- /dev/null +++ b/tools/install_venv_common.py @@ -0,0 +1,172 @@ +# Copyright 2013 OpenStack Foundation +# Copyright 2013 IBM Corp. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Provides methods needed by installation script for OpenStack development +virtual environments. + +Since this script is used to bootstrap a virtualenv from the system's Python +environment, it should be kept strictly compatible with Python 2.6. + +Synced in from openstack-common +""" + +from __future__ import print_function + +import optparse +import os +import subprocess +import sys + + +class InstallVenv(object): + + def __init__(self, root, venv, requirements, + test_requirements, py_version, + project): + self.root = root + self.venv = venv + self.requirements = requirements + self.test_requirements = test_requirements + self.py_version = py_version + self.project = project + + def die(self, message, *args): + print(message % args, file=sys.stderr) + sys.exit(1) + + def check_python_version(self): + if sys.version_info < (2, 6): + self.die("Need Python Version >= 2.6") + + def run_command_with_code(self, cmd, redirect_output=True, + check_exit_code=True): + """Runs a command in an out-of-process shell. + + Returns the output of that command. Working directory is self.root. + """ + if redirect_output: + stdout = subprocess.PIPE + else: + stdout = None + + proc = subprocess.Popen(cmd, cwd=self.root, stdout=stdout) + output = proc.communicate()[0] + if check_exit_code and proc.returncode != 0: + self.die('Command "%s" failed.\n%s', ' '.join(cmd), output) + return (output, proc.returncode) + + def run_command(self, cmd, redirect_output=True, check_exit_code=True): + return self.run_command_with_code(cmd, redirect_output, + check_exit_code)[0] + + def get_distro(self): + if (os.path.exists('/etc/fedora-release') or + os.path.exists('/etc/redhat-release')): + return Fedora( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + else: + return Distro( + self.root, self.venv, self.requirements, + self.test_requirements, self.py_version, self.project) + + def check_dependencies(self): + self.get_distro().install_virtualenv() + + def create_virtualenv(self, no_site_packages=True): + """Creates the virtual environment and installs PIP. + + Creates the virtual environment and installs PIP only into the + virtual environment. + """ + if not os.path.isdir(self.venv): + print('Creating venv...', end=' ') + if no_site_packages: + self.run_command(['virtualenv', '-q', '--no-site-packages', + self.venv]) + else: + self.run_command(['virtualenv', '-q', self.venv]) + print('done.') + else: + print("venv already exists...") + pass + + def pip_install(self, *args): + self.run_command(['tools/with_venv.sh', + 'pip', 'install', '--upgrade'] + list(args), + redirect_output=False) + + def install_dependencies(self): + print('Installing dependencies with pip (this can take a while)...') + + # First things first, make sure our venv has the latest pip and + # setuptools and pbr + self.pip_install('pip>=1.4') + self.pip_install('setuptools') + self.pip_install('pbr') + + self.pip_install('-r', self.requirements, '-r', self.test_requirements) + + def parse_args(self, argv): + """Parses command-line arguments.""" + parser = optparse.OptionParser() + parser.add_option('-n', '--no-site-packages', + action='store_true', + help="Do not inherit packages from global Python " + "install") + return parser.parse_args(argv[1:])[0] + + +class Distro(InstallVenv): + + def check_cmd(self, cmd): + return bool(self.run_command(['which', cmd], + check_exit_code=False).strip()) + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if self.check_cmd('easy_install'): + print('Installing virtualenv via easy_install...', end=' ') + if self.run_command(['easy_install', 'virtualenv']): + print('Succeeded') + return + else: + print('Failed') + + self.die('ERROR: virtualenv not found.\n\n%s development' + ' requires virtualenv, please install it using your' + ' favorite package management tool' % self.project) + + +class Fedora(Distro): + """This covers all Fedora-based distributions. + + Includes: Fedora, RHEL, CentOS, Scientific Linux + """ + + def check_pkg(self, pkg): + return self.run_command_with_code(['rpm', '-q', pkg], + check_exit_code=False)[1] == 0 + + def install_virtualenv(self): + if self.check_cmd('virtualenv'): + return + + if not self.check_pkg('python-virtualenv'): + self.die("Please install 'python-virtualenv'.") + + super(Fedora, self).install_virtualenv() diff --git a/tools/register_plugin.sh b/tools/register_plugin.sh new file mode 100755 index 0000000..7b24c8c --- /dev/null +++ b/tools/register_plugin.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +src_path=`cd "$1"; pwd` +dest_path=`cd "$2"; pwd` +# echo "$src_path --> $dest_path" + +for filepath in $src_path/watcher_dashboard/enabled/*.py; do + filename=$(basename $filepath) + if [ $filename != "__init__.py" ]; then + echo $filepath + src_filepath="`cd "$(dirname $filepath)"; pwd`/$filename" + dest_filepath="$dest_path/openstack_dashboard/local/enabled/$filename" + echo "$src_filepath --> $dest_filepath" + ln -s $src_filepath $dest_filepath + fi +done diff --git a/tools/with_venv.sh b/tools/with_venv.sh new file mode 100755 index 0000000..f4170c9 --- /dev/null +++ b/tools/with_venv.sh @@ -0,0 +1,13 @@ +#!/bin/bash +TOOLS_PATH=${TOOLS_PATH:-$(dirname $0)} +VENV_PATH=${VENV_PATH:-${TOOLS_PATH}} +VENV_DIR=${VENV_NAME:-/../.venv} +TOOLS=${TOOLS_PATH} +VENV=${VENV:-${VENV_PATH}/${VENV_DIR}} +HORIZON_DIR=${TOOLS%/tools} + +# This horrible mangling of the PYTHONPATH is required to get the +# babel-angular-gettext extractor to work. To fix this the extractor needs to +# be packaged on pypi and added to global requirements. That work is in progress. +export PYTHONPATH="$HORIZON_DIR" +source ${VENV}/bin/activate && "$@" diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..be72d5d --- /dev/null +++ b/tox.ini @@ -0,0 +1,58 @@ +[tox] +minversion = 1.6 +envlist = py34,py27,pep8 +skipsdist = True + +[testenv] +usedevelop = True +install_command = pip install -U {opts} {packages} +setenv = VIRTUAL_ENV={envdir} + NOSE_WITH_OPENSTACK=1 + NOSE_OPENSTACK_COLOR=1 + NOSE_OPENSTACK_RED=0.05 + NOSE_OPENSTACK_YELLOW=0.025 + NOSE_OPENSTACK_SHOW_ELAPSED=1 + DJANGO_SETTINGS_MODULE=watcher_dashboard.test.settings +# Note the hash seed is set to 0 until horizon can be tested with a +# random hash seed successfully. + PYTHONHASHSEED=0 +whitelist_externals = /bin/bash +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + python manage.py test --settings=watcher_dashboard.test.settings \ + --exclude-dir=watcher_dashboard/test/integration_tests \ + watcher_dashboard.test + +[testenv:pep8] +commands = flake8 + +[testenv:venv] +commands = {posargs} + +# Django-1.8 is LTS +[testenv:py27dj18] +basepython = python2.7 +commands = pip install django>=1.8,<1.9 + /bin/bash run_tests.sh -N --no-pep8 {posargs} + +[testenv:py27integration] +basepython = python2.7 +commands = /bin/bash run_tests.sh -N --integration --selenium-headless {posargs} + +[testenv:cover] +commands = python setup.py testr --coverage --testr-args='{posargs}' + +[testenv:docs] +commands = python setup.py build_sphinx + +[testenv:debug] +commands = oslo_debug_helper {posargs} + +[flake8] +show-source = True +# E123, E125 skipped as they are invalid PEP-8. +# H405 multi line docstring summary not separated with an empty line +ignore = E123,E125,H405 +builtins = _ +exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,.ropeproject,tools diff --git a/watcher_dashboard/__init__.py b/watcher_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/api/__init__.py b/watcher_dashboard/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/api/watcher.py b/watcher_dashboard/api/watcher.py new file mode 100644 index 0000000..f90e4ca --- /dev/null +++ b/watcher_dashboard/api/watcher.py @@ -0,0 +1,430 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import unicode_literals + +import logging + +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from openstack_dashboard.api import base +from watcherclient import client as wc + +from watcher_dashboard.utils import errors as errors_utils + +LOG = logging.getLogger(__name__) +WATCHER_SERVICE = 'infra-optim' + + +def watcherclient(request, password=None): + api_version = "1" + insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False) + ca_file = getattr(settings, 'OPENSTACK_SSL_CACERT', None) + endpoint = base.url_for(request, WATCHER_SERVICE) + + LOG.debug('watcherclient connection created using token "%s" and url "%s"' + % (request.user.token.id, endpoint)) + + client = wc.get_client( + api_version, + watcher_url=endpoint, + insecure=insecure, + ca_file=ca_file, + username=request.user.username, + password=password, + os_auth_token=request.user.token.id + ) + return client + + +class Audit(base.APIResourceWrapper): + _attrs = ('uuid', 'created_at', 'modified_at', 'deleted_at', + 'deadline', 'state', 'type', 'audit_template_uuid', + 'audit_template_name') + + def __init__(self, apiresource, request=None): + super(Audit, self).__init__(apiresource) + self._request = request + + @classmethod + def create(cls, request, audit_template_uuid, type, deadline): + """Create an audit in Watcher + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_template: audit audit_template + :type audit_template: string + + :param type: audit type + :type type: string + + :param deadline: audit deadline + :type deadline: string + + :return: the created Audit object + :rtype: watcher_dashboard.api.watcher.Audit + """ + audit = watcherclient(request).audit.create( + audit_template_uuid=audit_template_uuid, type=type, + deadline=deadline) + return cls(audit, request=request) + + @classmethod + def list(cls, request, audit_template_filter): + """Return a list of audits in Watcher + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_template_filter: audit_template filter, name or uuid + :type audit_template_filter: string + + :return: list of audits, or an empty list if there are none + :rtype: list of watcher_dashboard.api.watcher.Audit + """ + audits = watcherclient(request).audit.list( + audit_template=audit_template_filter) + return [cls(audit, request=request) for audit in audits] + + @classmethod + @errors_utils.handle_errors(_("Unable to retrieve audit")) + def get(cls, request, audit_id): + """Return the audit that matches the ID + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_id: id of audit to be retrieved + :type audit_id: int + + :return: matching audit, or None if no audit matches + the ID + :rtype: watcher_dashboard.api.watcher.Audit + """ + audit = watcherclient(request).audit.get(audit_id=audit_id) + return cls(audit, request=request) + + @classmethod + def delete(cls, request, audit_id): + """Delete an audit + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_id: audit id + :type audit_id: int + """ + watcherclient(request).audit.delete(audit_id=audit_id) + + @property + def id(self): + return self.uuid + + +class AuditTemplate(base.APIDictWrapper): + _attrs = ('uuid', 'created_at', 'updated_at', 'deleted_at', + 'description', 'host_aggregate', 'name', + 'extra', 'goal') + + def __init__(self, apiresource, request=None): + super(AuditTemplate, self).__init__(apiresource) + self._request = request + + @classmethod + def create(cls, request, name, goal, description, host_aggregate): + """Create an audit template in Watcher + + :param request: request object + :type request: django.http.HttpRequest + + :param name: Name for this audit template + :type name: string + + :param goal: Goal Type associated to this audit template + :type goal: string + + :param description: Descrition of the audit template + :type description: string + + :param host_aggregate: Name or ID of the host aggregate targeted\ + by this audit template + :type host_aggregate: string + + :param audit_template: audit audit_template + :type audit_template: string + + :return: the created Audit Template object + :rtype: watcher_dashboard.api.watcher.AuditTemplate + """ + audit_template = watcherclient(request).audit_template.create( + name=name, + goal=goal, + description=description, + host_aggregate=host_aggregate + ) + + return audit_template + + @classmethod + def patch(cls, request, audit_template_id, parameters): + """Update an audit in Watcher + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_template_id: id of the audit template we want to update + :type audit_template_id: string + + :param parameters: new values for the audit template's parameters + :type parameters: dict + + :return: the updated Audit Template object + :rtype: watcher_dashboard.api.watcher.AuditTemplate + """ + parameter_list = [{ + 'name': str(name), + 'value': str(value), + } for (name, value) in parameters.items()] + audit_template = watcherclient(request).audit_template.patch( + audit_template_id, parameter_list) + return audit_template + + @classmethod + def list(cls, request, filter): + """Return a list of audit templates in Watcher + + :param request: request object + :type request: django.http.HttpRequest + + :param filter: audit template filter + :type filter: string + + :return: list of audit templates, or an empty list if there are none + :rtype: list of watcher_dashboard.api.watcher.AuditTemplate + """ + + audit_templates = watcherclient(request).audit_template.list( + name=filter) + return audit_templates + + @classmethod + @errors_utils.handle_errors(_("Unable to retrieve audit template")) + def get(cls, request, audit_template_id): + """Return the audit template that matches the ID + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_template_id: id of audit template to be retrieved + :type audit_template_id: int + + :return: matching audit template, or None if no audit template matches + the ID + :rtype: watcher_dashboard.api.watcher.AuditTemplate + """ + audit_template = watcherclient(request).audit_template.get( + audit_template_id=audit_template_id) + # return cls(audit, request=request) + return audit_template + + @classmethod + @errors_utils.handle_errors(_("Unable to retrieve audit template goal")) + def get_goals(cls, request): + """Return the audit template goal that matches the ID + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_template_id: id of audit template to be retrieved + :type audit_template_id: int + + :return: matching audit template, or None if no audit template matches + the ID + :rtype: watcher_dashboard.api.watcher.AuditTemplate + """ + + goals = watcherclient(request).goal.list() + return map(lambda goal: goal.name, goals) + + @classmethod + def delete(cls, request, audit_template_id): + """Delete an audit_template + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_template_id: audit id + :type audit_template_id: int + """ + watcherclient(request).audit_template.delete( + audit_template_id=audit_template_id) + + @property + def id(self): + return self.uuid + + +class ActionPlan(base.APIResourceWrapper): + _attrs = ('uuid', 'created_at', 'updated_at', 'deleted_at', + 'audit_uuid', 'state') + + def __init__(self, apiresource, request=None): + super(ActionPlan, self).__init__(apiresource) + self._request = request + + @classmethod + def list(cls, request, audit_filter): + """Return a list of action plans in Watcher + + :param request: request object + :type request: django.http.HttpRequest + + :param audit_filter: audit id filter + :type audit_filter: string + + :return: list of action plans, or an empty list if there are none + :rtype: list of watcher_dashboard.api.watcher.ActionPlan + """ + action_plans = watcherclient(request).action_plan.list( + audit=audit_filter) + return [cls(action_plan, request=request) + for action_plan in action_plans] + + @classmethod + @errors_utils.handle_errors(_("Unable to retrieve action plan")) + def get(cls, request, action_plan_id): + """Return the action plan that matches the ID + + :param request: request object + :type request: django.http.HttpRequest + + :param action_plan_id: id of action plan to be retrieved + :type action_plan_id: int + + :return: matching action plan, or None if no action plan matches + the ID + :rtype: watcher_dashboard.api.watcher.ActionPlan + """ + action_plan = watcherclient(request).action_plan.get( + action_plan_id=action_plan_id) + return cls(action_plan, request=request) + + @classmethod + def delete(cls, request, action_plan_id): + """Delete an action plan + + :param request: request object + :type request: django.http.HttpRequest + + :param action_plan_id: audit id + :type action_plan_id: int + """ + watcherclient(request).action_plan.delete( + action_plan_id=action_plan_id) + + @classmethod + def start(cls, request, action_plan_id): + """Start an Action Plan + + :param request: request object + :type request: django.http.HttpRequest + + :param action_plan_id: audit id + :type action_plan_id: int + """ + patch = [] + patch.append({'op': 'replace', 'path': '/state', 'value': 'TRIGGERED'}) + watcherclient(request).action_plan.update(action_plan_id, patch) + + @property + def id(self): + return self.uuid + + +class Action(base.APIResourceWrapper): + _attrs = ('uuid', 'created_at', 'updated_at', 'deleted_at', 'next_uuid', + 'description', 'alarm', 'state', 'action_plan_uuid', + 'action_type', 'applies_to', 'src', 'dst', 'parameter') + + def __init__(self, apiresource, request=None): + super(Action, self).__init__(apiresource) + self._request = request + + @classmethod + def list(cls, request, action_plan_filter): + """Return a list of actions in Watcher + + :param request: request object + :type request: django.http.HttpRequest + + :param action_plan_filter: action_plan id filter + :type action_plan_filter: string + + :return: list of actions, or an empty list if there are none + :rtype: list of watcher_dashboard.api.watcher.Action + """ + + actions = watcherclient(request).action.list( + action_plan=action_plan_filter, detail=True) + return [cls(action, request=request) + for action in actions] + + @classmethod + @errors_utils.handle_errors(_("Unable to retrieve action")) + def get(cls, request, action_id): + """Return the action that matches the ID + + :param request: request object + :type request: django.http.HttpRequest + + :param action_id: id of action to be retrieved + :type action_id: int + + :return: matching action, or None if no action matches + the ID + :rtype: watcher_dashboard.api.watcher.Action + """ + action = watcherclient(request).action.get( + action_id=action_id) + return cls(action, request=request) + + @classmethod + def delete(cls, request, action_id): + """Delete an action + + :param request: request object + :type request: django.http.HttpRequest + + :param action_id: action_plan id + :type action_id: int + """ + watcherclient(request).action.delete( + action_id=action_id) + + @classmethod + def start(cls, request, action_id): + """Start an Action Plan + + :param request: request object + :type request: django.http.HttpRequest + + :param action_id: action_plan id + :type action_id: int + """ + patch = [] + patch.append({'op': 'replace', 'path': '/state', 'value': 'TRIGGERED'}) + watcherclient(request).action.update(action_id, patch) + + @property + def id(self): + return self.uuid diff --git a/watcher_dashboard/common/__init__.py b/watcher_dashboard/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/common/exceptions.py b/watcher_dashboard/common/exceptions.py new file mode 100644 index 0000000..90c60ff --- /dev/null +++ b/watcher_dashboard/common/exceptions.py @@ -0,0 +1,24 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from openstack_dashboard import exceptions +from watcherclient.common.apiclient import exceptions as watcherclient + +NOT_FOUND = exceptions.NOT_FOUND +RECOVERABLE = exceptions.RECOVERABLE + ( + watcherclient.ClientException, +) +UNAUTHORIZED = exceptions.UNAUTHORIZED diff --git a/watcher_dashboard/content/__init__.py b/watcher_dashboard/content/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/content/action_plans/__init__.py b/watcher_dashboard/content/action_plans/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/content/action_plans/panel.py b/watcher_dashboard/content/action_plans/panel.py new file mode 100644 index 0000000..e5c5259 --- /dev/null +++ b/watcher_dashboard/content/action_plans/panel.py @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +import horizon + + +class ActionPlans(horizon.Panel): + name = _("Action Plans") + slug = "action_plans" diff --git a/watcher_dashboard/content/action_plans/tables.py b/watcher_dashboard/content/action_plans/tables.py new file mode 100644 index 0000000..fc82213 --- /dev/null +++ b/watcher_dashboard/content/action_plans/tables.py @@ -0,0 +1,182 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from django.template.defaultfilters import title # noqa +from django.utils.translation import pgettext_lazy +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy +import horizon.exceptions +import horizon.messages +import horizon.tables +from horizon.utils import filters + +from watcher_dashboard.api import watcher + +LOG = logging.getLogger(__name__) + +ACTION_PLAN_STATE_DISPLAY_CHOICES = ( + ("NO STATE", pgettext_lazy("State of an action plan", u"No State")), + ("ONGOING", pgettext_lazy("State of an action plan", u"On Going")), + ("SUCCESS", pgettext_lazy("State of an action plan", u"Sucess")), + ("SUBMITTED", pgettext_lazy("State of an action plan", u"Submitted")), + ("FAILED", pgettext_lazy("State of an action plan", u"Failed")), + ("DELETED", pgettext_lazy("State of an action plan", u"Deleted")), + ("RECOMMENDED", pgettext_lazy("State of an action plan", u"Recommended")), +) + + +class ActionPlansFilterAction(horizon.tables.FilterAction): + # server = choices query = text + filter_type = "server" + filter_choices = ( + ('audit_filter', _("Audit ="), True), + ) + + +class ArchiveActionPlan(horizon.tables.BatchAction): + name = "archive_action_plans" + # policy_rules = (("compute", "compute:delete"),) + help_text = _("Archive an action plan.") + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Archive Action Plan", + u"Archive Action Plans", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Action Plan archived", + u"Action Plans archived", + count + ) + + def action(self, request, obj_id): + watcher.ActionPlan.delete(request, obj_id) + + +class StartActionPlan(horizon.tables.BatchAction): + name = "start_action_plan" + classes = ('btn-confirm',) + # policy_rules = (("compute", "compute:delete"),) + help_text = _("Execute an action plan.") + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Start Action Plan", + u"Start Action Plans", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Action Plan started", + u"Action Plans started", + count + ) + + def action(self, request, action_plan_id): + try: + watcher.ActionPlan.start(request, action_plan_id) + except Exception: + msg = _('Failed to start the action plan.') + LOG.info(msg) + horizon.messages.warning(request, msg) + + def allowed(self, request, action_plan): + return ((action_plan is None) or + (action_plan.state in ("RECOMMENDED", "FAILED"))) + + +class UpdateRow(horizon.tables.Row): + ajax = True + + def get_data(self, request, action_plan_id): + action_plan = None + + try: + action_plan = watcher.Action.get(request, action_plan_id) + except Exception: + msg = _('Failed to get the action_plan.') + LOG.info(msg) + horizon.messages.warning(request, msg) + + return action_plan + + +# class CancelActionPlan(horizon.tables.DeleteAction): +# verbose_name = _(u"Cancel ActionPlans") +# icon = "trash" + +# @staticmethod +# def action_present(count): +# return ungettext_lazy( +# u"Cancel ActionPlan", +# u"Cancel ActionPlans", +# count +# ) + +# @staticmethod +# def action_past(count): +# return ungettext_lazy( +# u"Canceled ActionPlan", +# u"canceled ActionPlans", +# count +# ) + + +class ActionPlansTable(horizon.tables.DataTable): + name = horizon.tables.Column( + 'id', + verbose_name=_("ID"), + link="horizon:admin:action_plans:detail") + audit = horizon.tables.Column( + 'audit_uuid', + verbose_name=_('Audit'), + filters=(title, filters.replace_underscores)) + updated_at = horizon.tables.Column( + 'updated_at', + filters=(filters.parse_isotime, + filters.timesince_sortable), + verbose_name=_("Updated At")) + status = horizon.tables.Column( + 'state', + verbose_name=_('State'), + status=True, + status_choices=ACTION_PLAN_STATE_DISPLAY_CHOICES) + + class Meta(object): + name = "action_plans" + verbose_name = _("ActionPlans") + table_actions = ( + # CancelActionPlan, + ActionPlansFilterAction, + ) + row_actions = ( + StartActionPlan, + # CreateActionPlans, + ArchiveActionPlan, + # CreateActionPlans, + # DeleteActionPlans, + ) + row_class = UpdateRow diff --git a/watcher_dashboard/content/action_plans/tabs.py b/watcher_dashboard/content/action_plans/tabs.py new file mode 100644 index 0000000..a395e7c --- /dev/null +++ b/watcher_dashboard/content/action_plans/tabs.py @@ -0,0 +1,33 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +from horizon import tabs + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = "infra_optim/action_plans/_detail_overview.html" + + def get_context_data(self, request): + return {"action_plan": self.tab_group.kwargs['action_plans']} + + +class ActionPlanDetailTabs(tabs.TabGroup): + slug = "action_plan_details" + tabs = (OverviewTab,) + sticky = True diff --git a/watcher_dashboard/content/action_plans/urls.py b/watcher_dashboard/content/action_plans/urls.py new file mode 100644 index 0000000..27ce1e1 --- /dev/null +++ b/watcher_dashboard/content/action_plans/urls.py @@ -0,0 +1,30 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.conf import urls + +from watcher_dashboard.content.action_plans import views + + +urlpatterns = urls.patterns( + 'watcher_dashboard.content.action_plans.views', + urls.url(r'^$', + views.IndexView.as_view(), name='index'), + urls.url(r'^(?P[^/]+)/detail$', + views.DetailView.as_view(), name='detail'), + urls.url(r'^archive/$', + views.ArchiveView.as_view(), name='archive'), +) diff --git a/watcher_dashboard/content/action_plans/views.py b/watcher_dashboard/content/action_plans/views.py new file mode 100644 index 0000000..5b20aaa --- /dev/null +++ b/watcher_dashboard/content/action_plans/views.py @@ -0,0 +1,134 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from django.utils.translation import ugettext_lazy as _ +import horizon.exceptions +from horizon import forms +import horizon.tables +import horizon.tabs +from horizon.utils import memoized +import horizon.workflows + +from watcher_dashboard.api import watcher +from watcher_dashboard.content.action_plans import tables +from watcher_dashboard.content.actions import tables as action_tables +from watcher_dashboard.content.audits import forms as wforms + +LOG = logging.getLogger(__name__) + + +class IndexView(horizon.tables.DataTableView): + table_class = tables.ActionPlansTable + template_name = 'infra_optim/action_plans/index.html' + + def get_context_data(self, **kwargs): + context = super(IndexView, self).get_context_data(**kwargs) + context['action_plans_count'] = self.get_action_plans_count() + return context + + def get_data(self): + action_plans = [] + search_opts = self.get_filters() + try: + action_plans = watcher.ActionPlan.list( + self.request, + audit_filter=search_opts) + except Exception: + horizon.exceptions.handle( + self.request, + _("Unable to retrieve action_plan information.")) + return action_plans + + def get_action_plans_count(self): + return len(self.get_data()) + + def get_filters(self): + filter = None + filter_action = self.table._meta._filter_action + if filter_action: + filter_field = self.table.get_filter_field() + if filter_action.is_api_filter(filter_field): + filter_string = self.table.get_filter_string() + if filter_field and filter_string: + filter = filter_string + return filter + + +class ArchiveView(forms.ModalFormView): + form_class = wforms.CreateForm + form_id = "create_audit_form" + modal_header = _("Create Audit") + template_name = 'infra_optim/audits/create.html' + page_title = _("Create Audit") + submit_label = _("Create Audit") + + +class DetailView(horizon.tables.MultiTableView): + table_classes = (action_tables.ActionsTable,) + template_name = 'infra_optim/action_plans/details.html' + page_title = _("Action Plan Details: {{ action_plan.uuid }}") + + @memoized.memoized_method + def _get_data(self): + action_plan_id = None + try: + action_plan_id = self.kwargs['action_plan_id'] + action_plan = watcher.ActionPlan.get(self.request, action_plan_id) + except Exception: + msg = _('Unable to retrieve details for action_plan "%s".') \ + % action_plan_id + horizon.exceptions.handle( + self.request, msg, + redirect=self.redirect_url) + return action_plan + + def get_wactions_data(self): + try: + action_plan = self._get_data() + actions = watcher.Action.list(self.request, + action_plan_filter=action_plan.id) + except Exception: + actions = [] + msg = _('Action list can not be retrieved.') + horizon.exceptions.handle(self.request, msg) + return actions + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + action_plan = self._get_data() + context["action_plan"] = action_plan + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info(action_plan) + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info('*********************************') + LOG.info('*********************************') + return context + + def get_tabs(self, request, *args, **kwargs): + action_plan = self._get_data() + # ports = self._get_ports() + return self.tab_group_class(request, action_plan=action_plan, + # ports=ports, + **kwargs) diff --git a/watcher_dashboard/content/actions/__init__.py b/watcher_dashboard/content/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/content/actions/panel.py b/watcher_dashboard/content/actions/panel.py new file mode 100644 index 0000000..0e05234 --- /dev/null +++ b/watcher_dashboard/content/actions/panel.py @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +import horizon + + +class Actions(horizon.Panel): + name = _("Actions ") + slug = "actions" diff --git a/watcher_dashboard/content/actions/tables.py b/watcher_dashboard/content/actions/tables.py new file mode 100644 index 0000000..1449430 --- /dev/null +++ b/watcher_dashboard/content/actions/tables.py @@ -0,0 +1,89 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from django.template.defaultfilters import title # noqa +from django.utils.translation import pgettext_lazy +from django.utils.translation import ugettext_lazy as _ +import horizon.exceptions +import horizon.messages +import horizon.tables +from horizon.utils import filters + +from watcher_dashboard.api import watcher + +LOG = logging.getLogger(__name__) + + +ACTION_STATE_DISPLAY_CHOICES = ( + ("NO STATE", pgettext_lazy("Power state of an Instance", u"No State")), + ("ONGOING", pgettext_lazy("Power state of an Instance", u"On Going")), + ("SUCCESS", pgettext_lazy("Power state of an Instance", u"Success")), + ("CANCELLED", pgettext_lazy("Power state of an Instance", u"Cancelled")), + ("FAILED", pgettext_lazy("Power state of an Instance", u"Failed")), + ("DELETED", pgettext_lazy("Power state of an Instance", u"Deleted")), + ("PENDING", pgettext_lazy("Power state of an Instance", u"Pending")), +) + + +class UpdateRow(horizon.tables.Row): + ajax = True + + def get_data(self, request, action_id): + action = None + try: + action = watcher.Action.get(request, action_id) + except Exception: + msg = _('Failed to get the action.') + LOG.info(msg) + horizon.messages.warning(request, msg) + + return action + + +class ActionsFilterAction(horizon.tables.FilterAction): + filter_type = "server" + filter_choices = (('action_plan', _("Action Plan ID ="), True),) + + +class ActionsTable(horizon.tables.DataTable): + name = horizon.tables.Column( + 'id', + verbose_name=_("ID")) + action_type = horizon.tables.Column( + 'action_type', + verbose_name=_('Type'), + filters=(title, filters.replace_underscores)) + description = horizon.tables.Column( + 'description', + verbose_name=_('Description')) + state = horizon.tables.Column( + 'state', + verbose_name=_('State'), + status_choices=ACTION_STATE_DISPLAY_CHOICES) + + next_action = horizon.tables.Column( + 'next_uuid', + verbose_name=_('Next Action')) + ajax = True + + class Meta(object): + name = "wactions" + verbose_name = _("Actions") + table_actions = (ActionsFilterAction, ) + hidden_title = False + row_class = UpdateRow diff --git a/watcher_dashboard/content/actions/tabs.py b/watcher_dashboard/content/actions/tabs.py new file mode 100644 index 0000000..2b4c5fa --- /dev/null +++ b/watcher_dashboard/content/actions/tabs.py @@ -0,0 +1,33 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +from horizon import tabs + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = "infra_optim/actions/_detail_overview.html" + + def get_context_data(self, request): + return {"action": self.tab_group.kwargs['action']} + + +class ActionDetailTabs(tabs.TabGroup): + slug = "action_details" + tabs = (OverviewTab,) + sticky = True diff --git a/watcher_dashboard/content/actions/urls.py b/watcher_dashboard/content/actions/urls.py new file mode 100644 index 0000000..c3bc5a8 --- /dev/null +++ b/watcher_dashboard/content/actions/urls.py @@ -0,0 +1,28 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.conf import urls + +from watcher_dashboard.content.actions import views + + +urlpatterns = urls.patterns( + 'watcher_dashboard.content.actions.views', + urls.url(r'^$', + views.IndexView.as_view(), name='index'), + urls.url(r'^(?P[^/]+)/$', + views.DetailView.as_view(), name='details'), +) diff --git a/watcher_dashboard/content/actions/views.py b/watcher_dashboard/content/actions/views.py new file mode 100644 index 0000000..3399c7f --- /dev/null +++ b/watcher_dashboard/content/actions/views.py @@ -0,0 +1,96 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +import horizon.exceptions +import horizon.tables +import horizon.tabs +from horizon.utils import memoized +import horizon.workflows + +from watcher_dashboard.api import watcher +from watcher_dashboard.content.actions import tables +from watcher_dashboard.content.actions import tabs as wtabs + + +class IndexView(horizon.tables.DataTableView): + table_class = tables.ActionsTable + template_name = 'infra_optim/actions/index.html' + + def get_context_data(self, **kwargs): + context = super(IndexView, self).get_context_data(**kwargs) + context['audits_count'] = self.get_actions_count() + return context + + def get_data(self): + actions = [] + search_opts = self.get_filters() + try: + actions = watcher.Action.list(self.request, + action_plan_filter=search_opts) + except Exception: + horizon.exceptions.handle( + self.request, + _("Unable to retrieve action information.")) + return actions + + def get_actions_count(self): + return len(self.get_data()) + + def get_filters(self): + filter = None + filter_action = self.table._meta._filter_action + if filter_action: + filter_field = self.table.get_filter_field() + if filter_action.is_api_filter(filter_field): + filter_string = self.table.get_filter_string() + if filter_field and filter_string: + filter = filter_string + return filter + + +class DetailView(horizon.tabs.TabbedTableView): + tab_group_class = wtabs.ActionDetailTabs + template_name = 'infra_optim/actions/details.html' + redirect_url = 'horizon:admin:actions:index' + page_title = _("Action Details: {{ action.uuid }}") + + @memoized.memoized_method + def _get_data(self): + action_plan_id = None + try: + action_plan_id = self.kwargs['action_plan_id'] + action = watcher.Action.get(self.request, action_plan_id) + except Exception: + msg = _('Unable to retrieve details for action "%s".') \ + % action_plan_id + horizon.exceptions.handle( + self.request, msg, + redirect=self.redirect_url) + return action + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + action = self._get_data() + context["action"] = action + return context + + def get_tabs(self, request, *args, **kwargs): + action = self._get_data() + # ports = self._get_ports() + return self.tab_group_class(request, action=action, + # ports=ports, + **kwargs) diff --git a/watcher_dashboard/content/audit_templates/__init__.py b/watcher_dashboard/content/audit_templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/content/audit_templates/forms.py b/watcher_dashboard/content/audit_templates/forms.py new file mode 100644 index 0000000..5079ac4 --- /dev/null +++ b/watcher_dashboard/content/audit_templates/forms.py @@ -0,0 +1,85 @@ +# Copyright 2012, Nachi Ueno, NTT MCL, Inc. +# All rights reserved. + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Forms for starting Watcher Audit Templates. +""" +import logging + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from watcher_dashboard.api import watcher + +LOG = logging.getLogger(__name__) + + +class CreateForm(forms.SelfHandlingForm): + name = forms.CharField(max_length=255, label=_("Name")) + description = forms.CharField(max_length=255, label=_("Description"), + required=False) + goal = forms.ChoiceField(label=_('Goal'), + required=True, + ) + + failure_url = 'horizon:admin:audit_templates:index' + + def __init__(self, request, *args, **kwargs): + super(CreateForm, self).__init__(request, *args, **kwargs) + goals = self._get_goal_list(request) + if goals: + self.fields['goal'].choices = goals + else: + del self.fields['goal'] + + def _get_goal_list(self, request): + try: + goals = watcher.AuditTemplate.get_goals(self.request) + except Exception as exc: + msg = _('Failed to get goals list.') + LOG.info(msg) + messages.warning(request, msg) + messages.warning(request, exc) + goals = [] + + choices = [ + (goal, goal) + for goal in goals + ] + + if choices: + choices.insert(0, ("", _("Select Goal"))) + return choices + + def handle(self, request, data): + try: + params = {'name': data['name']} + params['goal'] = data['goal'] + params['description'] = data['description'] + params['host_aggregate'] = None + audit_temp = watcher.AuditTemplate.create(request, **params) + message = _('Audit Template was successfully created.') + messages.success(request, message) + return audit_temp + except Exception as exc: + msg = _('Failed to create audit template"%s".') % data['name'] + LOG.info(exc) + redirect = reverse(self.failure_url) + exceptions.handle(request, msg, redirect=redirect) + return False diff --git a/watcher_dashboard/content/audit_templates/panel.py b/watcher_dashboard/content/audit_templates/panel.py new file mode 100644 index 0000000..baee95a --- /dev/null +++ b/watcher_dashboard/content/audit_templates/panel.py @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +import horizon + + +class AuditTemplates(horizon.Panel): + name = _("Audit Templates") + slug = "audit_templates" diff --git a/watcher_dashboard/content/audit_templates/tables.py b/watcher_dashboard/content/audit_templates/tables.py new file mode 100644 index 0000000..070b1c5 --- /dev/null +++ b/watcher_dashboard/content/audit_templates/tables.py @@ -0,0 +1,145 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import unicode_literals + +from django.utils.translation import pgettext_lazy +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy +import horizon.exceptions +import horizon.messages +import horizon.tables + +from watcher_dashboard.api import watcher + + +AUDIT_TEMPLATE_GOAL_DISPLAY_CHOICES = ( + ("BASIC_CONSOLIDATION", pgettext_lazy( + "Goal of an Audit", + "Consolidate Servers")), + ("MINIMIZE_ENERGY_CONSUMPTION", pgettext_lazy( + "Goal of an Audit", + "Minimize Energy")), + ("BALANCE_LOAD", pgettext_lazy( + "Goal of an Audit", + "Load Balancing")), + ("MINIMIZE_LICENSING_COST", pgettext_lazy( + "Goal of an Audit", + "Minimize Licensing Cost")), + ("PREPARED_PLAN_OPERATION", pgettext_lazy( + "Goal of an Audit", + "Prepared Plan Operation")), +) + + +class CreateAuditTemplates(horizon.tables.LinkAction): + name = "create" + verbose_name = _("Create Template") + url = "horizon:admin:audit_templates:create" + classes = ("ajax-modal", "btn-launch") + + +class AuditTemplatesFilterAction(horizon.tables.FilterAction): + filter_type = "server" + filter_choices = ( + ('name', _("Template Name ="), True), + ) + + +class LaunchAudit(horizon.tables.BatchAction): + name = "launch_audit" + verbose_name = _("Launch Audit") + data_type_singular = _("Launch Audit") + data_type_plural = _("Launch Audits") + success_url = "horizon:admin:audits:index" + # icon = "cloud-upload" + # policy_rules = (("compute", "compute:create"),) + + @staticmethod + def action_present(count): + return ungettext_lazy( + "Launch Audit", + "Launch Audits", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + "Launched Audit", + "Launched Audits", + count + ) + + def action(self, request, obj_id): + params = {'audit_template_uuid': obj_id} + params['type'] = 'ONE_SHOT' + params['deadline'] = None + watcher.Audit.create(request, **params) + + +class DeleteAuditTemplates(horizon.tables.DeleteAction): + verbose_name = _("Delete Templates") + + @staticmethod + def action_present(count): + return ungettext_lazy( + "Delete Template", + "Delete Templates", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + "Deleted Template", + "Deleted Templates", + count + ) + + def delete(self, request, obj_id): + watcher.AuditTemplate.delete(request, obj_id) + + +class AuditTemplatesTable(horizon.tables.DataTable): + name = horizon.tables.Column( + 'name', + verbose_name=_("Name"), + link="horizon:admin:audit_templates:detail") + goal = horizon.tables.Column( + 'goal', + verbose_name=_('Goal'), + status=True, + status_choices=AUDIT_TEMPLATE_GOAL_DISPLAY_CHOICES + ) + + def get_object_id(self, datum): + return datum.uuid + + class Meta(object): + name = "audit_templates" + verbose_name = _("Available") + table_actions = ( + CreateAuditTemplates, + DeleteAuditTemplates, + AuditTemplatesFilterAction, + # LaunchAuditTemplates, + ) + row_actions = ( + LaunchAudit, + # CreateAuditTemplates, + # DeleteAuditTemplates, + ) diff --git a/watcher_dashboard/content/audit_templates/tabs.py b/watcher_dashboard/content/audit_templates/tabs.py new file mode 100644 index 0000000..06e11b5 --- /dev/null +++ b/watcher_dashboard/content/audit_templates/tabs.py @@ -0,0 +1,33 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +from horizon import tabs + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = "infra_optim/audit_templates/_detail_overview.html" + + def get_context_data(self, request): + return {"audit_template": self.tab_group.kwargs['audit_template']} + + +class AuditTemplateDetailTabs(tabs.TabGroup): + slug = "audit_template_details" + tabs = (OverviewTab,) + sticky = True diff --git a/watcher_dashboard/content/audit_templates/tests.py b/watcher_dashboard/content/audit_templates/tests.py new file mode 100644 index 0000000..e765141 --- /dev/null +++ b/watcher_dashboard/content/audit_templates/tests.py @@ -0,0 +1,148 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from django.core import urlresolvers +from django import http +from mox3.mox import IsA # noqa + +from watcher_dashboard import api +from watcher_dashboard.test import helpers as test + +LOG = logging.getLogger(__name__) + +INDEX_URL = urlresolvers.reverse( + 'horizon:admin:audit_templates:index') +CREATE_URL = urlresolvers.reverse( + 'horizon:admin:audit_templates:create') +DETAILS_VIEW = 'horizon:admin:audit_templates:detail' + + +class AuditTemplatesTest(test.BaseAdminViewTests): + + goal_list = [ + 'BASIC_CONSOLIDATION', + 'MINIMIZE_ENERGY_CONSUMPTION', + 'BALANCE_LOAD', + 'MINIMIZE_LICENSING_COST', + 'PREPARED_PLAN_OPERATION', + ] + + @test.create_stubs({api.watcher.AuditTemplate: ('list',)}) + def test_index(self): + search_opts = None + api.watcher.AuditTemplate.list( + IsA(http.HttpRequest), + filter=search_opts).MultipleTimes().AndReturn( + self.audit_templates.list()) + self.mox.ReplayAll() + + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, 'infra_optim/audit_templates/index.html') + audit_templates = res.context['audit_templates_table'].data + self.assertItemsEqual(audit_templates, self.audit_templates.list()) + + @test.create_stubs({api.watcher.AuditTemplate: ('list',)}) + def test_audit_template_list_unavailable(self): + search_opts = None + api.watcher.AuditTemplate.list( + IsA(http.HttpRequest), + filter=search_opts).MultipleTimes().AndRaise( + self.exceptions.watcher) + self.mox.ReplayAll() + + resp = self.client.get(INDEX_URL) + self.assertMessageCount(resp, error=1, warning=0) + + @test.create_stubs({api.watcher.AuditTemplate: ('get_goals',)}) + def test_create_get(self): + api.watcher.AuditTemplate.get_goals( + IsA(http.HttpRequest)).AndReturn(self.goal_list) + self.mox.ReplayAll() + res = self.client.get(CREATE_URL) + self.assertTemplateUsed(res, 'infra_optim/audit_templates/create.html') + + @test.create_stubs({api.watcher.AuditTemplate: ('create', + 'get_goals')}) + def test_create_post(self): + at = self.audit_templates.first() + api.watcher.AuditTemplate.get_goals( + IsA(http.HttpRequest)).AndReturn(self.goal_list) + params = {'name': at.name, + 'goal': at.goal, + 'description': at.description, + 'host_aggregate': at.host_aggregate, + } + api.watcher.AuditTemplate.create( + IsA(http.HttpRequest), **params).AndReturn(at) + self.mox.ReplayAll() + + form_data = {'name': at.name, + 'goal': at.goal, + 'description': at.description, + 'host_aggregate': at.host_aggregate, + } + res = self.client.post(CREATE_URL, form_data) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.watcher.AuditTemplate: ('get',)}) + def test_details(self): + at = self.audit_templates.first() + at_id = at.uuid + api.watcher.AuditTemplate.get( + IsA(http.HttpRequest), at_id).\ + MultipleTimes().AndReturn(at) + self.mox.ReplayAll() + DETAILS_URL = urlresolvers.reverse(DETAILS_VIEW, args=[at_id]) + res = self.client.get(DETAILS_URL) + self.assertTemplateUsed(res, + 'infra_optim/audit_templates/details.html') + audit_templates = res.context['audit_template'] + self.assertItemsEqual([audit_templates], [at]) + + @test.create_stubs({api.watcher.AuditTemplate: ('get',)}) + def test_details_exception(self): + at = self.audit_templates.first() + at_id = at.uuid + api.watcher.AuditTemplate.get(IsA(http.HttpRequest), at_id) \ + .AndRaise(self.exceptions.watcher) + + self.mox.ReplayAll() + + DETAILS_URL = urlresolvers.reverse(DETAILS_VIEW, args=[at_id]) + res = self.client.get(DETAILS_URL) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.watcher.AuditTemplate: ('delete', 'list')}) + def test_delete(self): + search_opts = None + at_list = self.audit_templates.list() + at = self.audit_templates.first() + at_id = at.uuid + api.watcher.AuditTemplate.list( + IsA(http.HttpRequest), + filter=search_opts).MultipleTimes().AndReturn( + at_list) + api.watcher.AuditTemplate.delete(IsA(http.HttpRequest), at_id) + self.mox.ReplayAll() + + form_data = {'action': 'audit_templates__delete', + 'object_ids': at_id} + + res = self.client.post(INDEX_URL, form_data) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/watcher_dashboard/content/audit_templates/urls.py b/watcher_dashboard/content/audit_templates/urls.py new file mode 100644 index 0000000..99e8316 --- /dev/null +++ b/watcher_dashboard/content/audit_templates/urls.py @@ -0,0 +1,29 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.conf import urls + +from watcher_dashboard.content.audit_templates import views + + +urlpatterns = urls.patterns( + 'watcher_dashboard.content.audit_templates.views', + urls.url(r'^$', views.IndexView.as_view(), name='index'), + urls.url(r'^create/$', views.CreateView.as_view(), name='create'), + urls.url(r'^(?P[^/]+)/detail$', + views.DetailView.as_view(), + name='detail'), +) diff --git a/watcher_dashboard/content/audit_templates/views.py b/watcher_dashboard/content/audit_templates/views.py new file mode 100644 index 0000000..d430372 --- /dev/null +++ b/watcher_dashboard/content/audit_templates/views.py @@ -0,0 +1,113 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ +import horizon.exceptions +from horizon import forms +import horizon.tables +import horizon.tabs +import horizon.workflows +import logging +from watcher_dashboard.api import watcher +from watcher_dashboard.content.audit_templates import forms as wforms +from watcher_dashboard.content.audit_templates import tables +from watcher_dashboard.content.audit_templates import tabs as wtabs + +LOG = logging.getLogger(__name__) + + +class IndexView(horizon.tables.DataTableView): + table_class = tables.AuditTemplatesTable + template_name = 'infra_optim/audit_templates/index.html' + page_title = _("Audit Templates") + + def get_context_data(self, **kwargs): + context = super(IndexView, self).get_context_data(**kwargs) + context['audit_templates_count'] = self.get_count() + return context + + def get_data(self): + audit_templates = [] + search_opts = self.get_filters() + try: + audit_templates = watcher.AuditTemplate.list(self.request, + filter=search_opts) + except Exception: + horizon.exceptions.handle( + self.request, + _("Unable to retrieve audit template information.")) + return audit_templates + + def get_count(self): + return len(self.get_data()) + + def get_filters(self): + filter = None + filter_action = self.table._meta._filter_action + if filter_action: + filter_field = self.table.get_filter_field() + if filter_action.is_api_filter(filter_field): + filter_string = self.table.get_filter_string() + if filter_field and filter_string: + filter = filter_string + return filter + + +class CreateView(forms.ModalFormView): + form_class = wforms.CreateForm + form_id = "create_audit_templates_form" + modal_header = _("Create Audit Template") + template_name = 'infra_optim/audit_templates/create.html' + success_url = reverse_lazy("horizon:admin:audit_templates:index") + page_title = _("Create an Audit Template") + submit_label = _("Create Audit Template") + submit_url = reverse_lazy("horizon:admin:audit_templates:create") + + +class DetailView(horizon.tabs.TabbedTableView): + tab_group_class = wtabs.AuditTemplateDetailTabs + template_name = 'infra_optim/audit_templates/details.html' + redirect_url = 'horizon:admin:audit_templates:index' + page_title = _("Audit Template Details: {{ audit_template.name }}") + + def _get_data(self): + audit_template_id = None + try: + LOG.info(self.kwargs) + audit_template_id = self.kwargs['audit_template_id'] + audit_template = watcher.AuditTemplate.get(self.request, + audit_template_id) + except Exception: + msg = _('Unable to retrieve details for audit template "%s".') \ + % audit_template_id + horizon.exceptions.handle( + self.request, msg, + redirect=self.redirect_url) + return audit_template + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + audit_template = self._get_data() + context["audit_template"] = audit_template + return context + + def get_tabs(self, request, *args, **kwargs): + audit_template = self._get_data() + # ports = self._get_ports() + return self.tab_group_class(request, audit_template=audit_template, + # ports=ports, + **kwargs) diff --git a/watcher_dashboard/content/audits/__init__.py b/watcher_dashboard/content/audits/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/content/audits/forms.py b/watcher_dashboard/content/audits/forms.py new file mode 100644 index 0000000..ffbec91 --- /dev/null +++ b/watcher_dashboard/content/audits/forms.py @@ -0,0 +1,81 @@ +# Copyright 2012, Nachi Ueno, NTT MCL, Inc. +# All rights reserved. + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Forms for starting Watcher Audits. +""" +import logging + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from watcher_dashboard.api import watcher + +LOG = logging.getLogger(__name__) + + +class CreateForm(forms.SelfHandlingForm): + audit_template = forms.ChoiceField(label=_("Audit Template"), + required=True) + failure_url = 'horizon:admin:audits:index' + + def __init__(self, request, *args, **kwargs): + super(CreateForm, self).__init__(request, *args, **kwargs) + audit_templates = self._get_audit_template_list(request) + if audit_templates: + self.fields['audit_template'].choices = audit_templates + else: + del self.fields['audit_template'] + + def _get_audit_template_list(self, request): + try: + audit_templates = watcher.AuditTemplate.list(self.request, None) + except Exception: + msg = _('Failed to get audit template list.') + LOG.info(msg) + messages.warning(request, msg) + audit_templates = [] + + choices = [ + (audit_template.uuid, audit_template.name or audit_template.uuid) + for audit_template in audit_templates + ] + + if choices: + choices.insert(0, ("", _("Select Audit Template"))) + return choices + + def handle(self, request, data): + try: + params = {'audit_template_uuid': data['audit_template']} + params['type'] = 'ONE_SHOT' + params['deadline'] = None + audit = watcher.Audit.create(request, **params) + message = _('Audit was successfully created.') + messages.success(request, message) + return audit + except Exception as exc: + if exc.status_code == 409: + msg = _('Quota exceeded for resource audit.') + else: + msg = _('Failed to create audit "%s".') % data['name'] + LOG.info(exc) + redirect = reverse(self.failure_url) + exceptions.handle(request, msg, redirect=redirect) + return False diff --git a/watcher_dashboard/content/audits/panel.py b/watcher_dashboard/content/audits/panel.py new file mode 100644 index 0000000..9e9a46b --- /dev/null +++ b/watcher_dashboard/content/audits/panel.py @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +import horizon + + +class Audits(horizon.Panel): + name = _("Audits") + slug = "audits" diff --git a/watcher_dashboard/content/audits/tables.py b/watcher_dashboard/content/audits/tables.py new file mode 100644 index 0000000..d409e8f --- /dev/null +++ b/watcher_dashboard/content/audits/tables.py @@ -0,0 +1,149 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.core import urlresolvers +from django import shortcuts +from django.template.defaultfilters import title # noqa +from django.utils.translation import pgettext_lazy +from django.utils.translation import ugettext_lazy as _ +import horizon.exceptions +import horizon.messages +import horizon.tables +from horizon.utils import filters +from watcher_dashboard.api import watcher + +import logging +LOG = logging.getLogger(__name__) + +AUDIT_STATE_DISPLAY_CHOICES = ( + ("NO STATE", pgettext_lazy("State of an audit", u"No State")), + ("ONGOING", pgettext_lazy("State of an audit", u"On Going")), + ("SUCCESS", pgettext_lazy("State of an audit", u"Sucess")), + ("SUBMITTED", pgettext_lazy("State of an audit", u"Submitted")), + ("FAILED", pgettext_lazy("State of an audit", u"Failed")), + ("DELETED", pgettext_lazy("State of an audit", u"Deleted")), + ("PENDING", pgettext_lazy("State of an audit", u"Pending")), +) + + +class AuditsFilterAction(horizon.tables.FilterAction): + # server = choices query = text + filter_type = "server" + filter_choices = ( + ('audit_template_filter', _("Audit Template ="), True), + ) + + +class CreateAudit(horizon.tables.LinkAction): + name = "launch_audit" + verbose_name = _("Launch Audit") + url = "horizon:admin:audits:create" + classes = ("ajax-modal", "btn-launch") + # policy_rules = (("compute", "compute:create"),) + + +# class ArchiveAudits(horizon.tables.LinkAction): +# name = "archive_audits" +# verbose_name = _("Archive Audits") +# url = "horizon:project:instances:launch" +# classes = ("ajax-modal", "btn-launch") +# icon = "folder-open" + +class GoToActionPlan(horizon.tables.Action): + name = "go_to_action_plan" + verbose_name = _("Go to Action Plan") + url = "horizon:admin:action_plans:detail" + # classes = ("ajax-modal", "btn-launch") + # icon = "send" + + def allowed(self, request, audit): + return ((audit is None) or + (audit.state in ("SUCCESS"))) + + def single(self, table, request, audit_id): + try: + action_plans = watcher.ActionPlan.list( + request, + audit_filter=audit_id) + except Exception: + horizon.exceptions.handle( + request, + _("Unable to retrieve action_plan information.")) + return "javascript:void(0);" + + return shortcuts.redirect(urlresolvers.reverse( + self.url, + args=[action_plans[0].uuid])) + + +class GoToAuditTemplate(horizon.tables.Action): + name = "go_to_audit_template" + verbose_name = _("Go to Audit Template") + url = "horizon:admin:audit_templates:detail" + # classes = ("ajax-modal", "btn-launch") + # icon = "send" + + def allowed(self, request, audit): + return ((audit is None) or + (audit.state in ("SUCCESS"))) + + def single(self, table, request, audit_id): + try: + audit = watcher.Audit.get( + request, audit_id=audit_id) + except Exception: + horizon.exceptions.handle( + request, + _("Unable to retrieve action_plan information.")) + return "javascript:void(0);" + + return shortcuts.redirect(urlresolvers.reverse( + self.url, + args=[audit.audit_template_uuid])) + + +class AuditsTable(horizon.tables.DataTable): + name = horizon.tables.Column( + 'id', + verbose_name=_("ID"), + link="horizon:admin:audits:detail") + audit_template = horizon.tables.Column( + 'audit_template_name', + verbose_name=_('Audit Template'), + filters=(title, filters.replace_underscores)) + status = horizon.tables.Column( + 'state', + verbose_name=_('State'), + status=True, + status_choices=AUDIT_STATE_DISPLAY_CHOICES) + + class Meta(object): + name = "audits" + verbose_name = _("Audits") + launch_actions = (CreateAudit,) + table_actions = launch_actions + ( + # CancelAudit, + AuditsFilterAction, + # ArchiveAudits, + ) + row_actions = ( + GoToActionPlan, + GoToAuditTemplate, + # CreateAudits, + # ArchiveAudits, + # CreateAudits, + # DeleteAudits, + ) diff --git a/watcher_dashboard/content/audits/tabs.py b/watcher_dashboard/content/audits/tabs.py new file mode 100644 index 0000000..284e460 --- /dev/null +++ b/watcher_dashboard/content/audits/tabs.py @@ -0,0 +1,33 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.utils.translation import ugettext_lazy as _ +from horizon import tabs + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = "infra_optim/audits/_detail_overview.html" + + def get_context_data(self, request): + return {"audit": self.tab_group.kwargs['audit']} + + +class AuditDetailTabs(tabs.TabGroup): + slug = "audit_details" + tabs = (OverviewTab,) + sticky = True diff --git a/watcher_dashboard/content/audits/urls.py b/watcher_dashboard/content/audits/urls.py new file mode 100644 index 0000000..7c96a7e --- /dev/null +++ b/watcher_dashboard/content/audits/urls.py @@ -0,0 +1,30 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.conf import urls + +from watcher_dashboard.content.audits import views + + +urlpatterns = urls.patterns( + 'watcher_dashboard.audits.views', + urls.url(r'^$', + views.IndexView.as_view(), name='index'), + urls.url(r'^create/$', + views.CreateView.as_view(), name='create'), + urls.url(r'^(?P[^/]+)/detail$', + views.DetailView.as_view(), name='detail'), +) diff --git a/watcher_dashboard/content/audits/views.py b/watcher_dashboard/content/audits/views.py new file mode 100644 index 0000000..a12868b --- /dev/null +++ b/watcher_dashboard/content/audits/views.py @@ -0,0 +1,117 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ +import horizon.exceptions +from horizon import forms +import horizon.tables +import horizon.tabs +from horizon.utils import memoized +import horizon.workflows +from watcher_dashboard.api import watcher +from watcher_dashboard.content.audits import forms as wforms +from watcher_dashboard.content.audits import tables +from watcher_dashboard.content.audits import tabs as wtabs + + +class IndexView(horizon.tables.DataTableView): + table_class = tables.AuditsTable + template_name = 'infra_optim/audits/index.html' + + def get_context_data(self, **kwargs): + context = super(IndexView, self).get_context_data(**kwargs) + create_action = { + 'name': _("New Audit"), + 'url': reverse('horizon:admin:audits:create'), + 'icon': 'fa-plus', + 'ajax_modal': True, + } + context['header_actions'] = [create_action] + context['audits_count'] = self.get_audits_count() + return context + + def get_data(self): + audits = [] + search_opts = self.get_filters() + try: + audits = watcher.Audit.list(self.request, + audit_template_filter=search_opts) + except Exception: + horizon.exceptions.handle( + self.request, + _("Unable to retrieve audit information.")) + return audits + + def get_audits_count(self): + return len(self.get_data()) + + def get_filters(self): + filter = None + filter_action = self.table._meta._filter_action + if filter_action: + filter_field = self.table.get_filter_field() + if filter_action.is_api_filter(filter_field): + filter_string = self.table.get_filter_string() + if filter_field and filter_string: + filter = filter_string + return filter + + +class CreateView(forms.ModalFormView): + form_class = wforms.CreateForm + form_id = "create_audit_form" + modal_header = _("Create Audit") + template_name = 'infra_optim/audits/create.html' + success_url = reverse_lazy("horizon:admin:audits:index") + page_title = _("Create Audit") + submit_label = _("Create Audit") + submit_url = reverse_lazy("horizon:admin:audits:create") + + +class DetailView(horizon.tabs.TabbedTableView): + tab_group_class = wtabs.AuditDetailTabs + template_name = 'infra_optim/audits/details.html' + redirect_url = 'horizon:admin:audits:index' + page_title = _("Audit Details: {{ audit.uuid }}") + + @memoized.memoized_method + def _get_data(self): + audit_id = None + try: + audit_id = self.kwargs['audit_id'] + audit = watcher.Audit.get(self.request, audit_id) + except Exception: + msg = _('Unable to retrieve details for audit "%s".') \ + % audit_id + horizon.exceptions.handle( + self.request, msg, + redirect=self.redirect_url) + return audit + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + audit = self._get_data() + context["audit"] = audit + return context + + def get_tabs(self, request, *args, **kwargs): + audit = self._get_data() + # ports = self._get_ports() + return self.tab_group_class(request, audit=audit, + # ports=ports, + **kwargs) diff --git a/watcher_dashboard/enabled/_31000_watcher_panelgroup.py b/watcher_dashboard/enabled/_31000_watcher_panelgroup.py new file mode 100644 index 0000000..dccad6e --- /dev/null +++ b/watcher_dashboard/enabled/_31000_watcher_panelgroup.py @@ -0,0 +1,36 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +# The slug of the panel group to be added to HORIZON_CONFIG. Required. +PANEL_GROUP = 'watcher' +# The display name of the PANEL_GROUP. Required. +PANEL_GROUP_NAME = _('Optimization') +# The slug of the dashboard the PANEL_GROUP associated with. Required. +PANEL_GROUP_DASHBOARD = 'admin' + +ADD_INSTALLED_APPS = ['watcher_dashboard'] + +# ADD_ANGULAR_MODULES = [ +# 'horizon.dashboard.watcher' +# ] + +# ADD_JS_FILES = [ +# 'horizon/lib/angular/angular-route.js' +# ] + +# ADD_SCSS_FILES = [ +# 'dashboard/watcher/watcher.scss' +# ] + +AUTO_DISCOVER_STATIC_FILES = False diff --git a/watcher_dashboard/enabled/_31010_audit_templates_panel.py b/watcher_dashboard/enabled/_31010_audit_templates_panel.py new file mode 100644 index 0000000..a01a187 --- /dev/null +++ b/watcher_dashboard/enabled/_31010_audit_templates_panel.py @@ -0,0 +1,23 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'audit_templates' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'admin' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'watcher' +# If set, it will update the default panel of the PANEL_DASHBOARD. +DEFAULT_PANEL = 'audit_templates' + +# Python panel class of the PANEL to be added. +ADD_PANEL = 'watcher_dashboard.content.audit_templates.panel.AuditTemplates' diff --git a/watcher_dashboard/enabled/_31020_audits_panel.py b/watcher_dashboard/enabled/_31020_audits_panel.py new file mode 100644 index 0000000..e97c44f --- /dev/null +++ b/watcher_dashboard/enabled/_31020_audits_panel.py @@ -0,0 +1,23 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'audits' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'admin' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'watcher' +# If set, it will update the default panel of the PANEL_DASHBOARD. +DEFAULT_PANEL = 'audits' + +# Python panel class of the PANEL to be added. +ADD_PANEL = 'watcher_dashboard.content.audits.panel.Audits' diff --git a/watcher_dashboard/enabled/_31030_action_plans_panel.py b/watcher_dashboard/enabled/_31030_action_plans_panel.py new file mode 100644 index 0000000..bcf73e6 --- /dev/null +++ b/watcher_dashboard/enabled/_31030_action_plans_panel.py @@ -0,0 +1,23 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'action_plans' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'admin' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'watcher' +# If set, it will update the default panel of the PANEL_DASHBOARD. +DEFAULT_PANEL = 'action_plans' + +# Python panel class of the PANEL to be added. +ADD_PANEL = 'watcher_dashboard.content.action_plans.panel.ActionPlans' diff --git a/watcher_dashboard/enabled/_31040_actions_panel.py b/watcher_dashboard/enabled/_31040_actions_panel.py new file mode 100644 index 0000000..420cb1c --- /dev/null +++ b/watcher_dashboard/enabled/_31040_actions_panel.py @@ -0,0 +1,23 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'actions' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'admin' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'watcher' +# If set, it will update the default panel of the PANEL_DASHBOARD. +DEFAULT_PANEL = 'actions' + +# Python panel class of the PANEL to be added. +ADD_PANEL = 'watcher_dashboard.content.actions.panel.Actions' diff --git a/watcher_dashboard/enabled/__init__.py b/watcher_dashboard/enabled/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/models.py b/watcher_dashboard/models.py new file mode 100644 index 0000000..c44f0f4 --- /dev/null +++ b/watcher_dashboard/models.py @@ -0,0 +1,15 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Stub file to work around django bug: https://code.djangoproject.com/ticket/7198 +""" diff --git a/watcher_dashboard/static/infra_optim/images/chevron.png b/watcher_dashboard/static/infra_optim/images/chevron.png new file mode 100644 index 0000000..4e4cef9 Binary files /dev/null and b/watcher_dashboard/static/infra_optim/images/chevron.png differ diff --git a/watcher_dashboard/static/infra_optim/images/power.png b/watcher_dashboard/static/infra_optim/images/power.png new file mode 100644 index 0000000..8d3590d Binary files /dev/null and b/watcher_dashboard/static/infra_optim/images/power.png differ diff --git a/watcher_dashboard/static/infra_optim/scss/infra_optim.scss b/watcher_dashboard/static/infra_optim/scss/infra_optim.scss new file mode 100644 index 0000000..ca82f2e --- /dev/null +++ b/watcher_dashboard/static/infra_optim/scss/infra_optim.scss @@ -0,0 +1,3 @@ +/* Additional CSS for infra_optim. */ +@import "/dashboard/scss/variables"; +@import "/bootstrap/scss/bootstrap/variables"; diff --git a/watcher_dashboard/static/infra_optim/tests/formset_table.js b/watcher_dashboard/static/infra_optim/tests/formset_table.js new file mode 100644 index 0000000..5339f43 --- /dev/null +++ b/watcher_dashboard/static/infra_optim/tests/formset_table.js @@ -0,0 +1,60 @@ +horizon.addInitFunction(function () { + module("Formset table (watcher.formset_table.js)"); + + test("Reenumerate rows", function () { + var html = $('#qunit-fixture'); + var table = html.find('table'); + var input = table.find('tbody tr#flavors__row__14 input').first(); + + input.attr('id', 'id_flavors-3-name'); + watcher.formset_table.reenumerate_rows(table, 'flavors'); + equal(input.attr('id'), 'id_flavors-0-name', "Enumerate old rows ids"); + input.attr('id', 'id_flavors-__prefix__-name'); + watcher.formset_table.reenumerate_rows(table, 'flavors'); + equal(input.attr('id'), 'id_flavors-0-name', "Enumerate new rows ids"); + }); + + test("Delete row", function () { + var html = $('#qunit-fixture'); + var table = html.find('table'); + var row = table.find('tbody tr').first(); + var input = row.find('input#id_flavors-0-DELETE'); + + equal(row.css("display"), 'table-row'); + equal(input.attr('checked'), undefined); + watcher.formset_table.replace_delete(row); + var x = input.next('a'); + watcher.formset_table.delete_row.call(x); + equal(row.css("display"), 'none'); + equal(input.attr('checked'), 'checked'); + }); + + test("Add row", function() { + var html = $('#qunit-fixture'); + var table = html.find('table'); + var empty_row_html = ''; + + equal(table.find('tbody tr').length, 3); + equal(html.find('#id_flavors-TOTAL_FORMS').val(), 3); + watcher.formset_table.add_row(table, 'flavors', empty_row_html); + equal(table.find('tbody tr').length, 4); + equal(table.find('tbody tr:last input').attr('id'), 'id_flavors-3-name'); + equal(html.find('#id_flavors-TOTAL_FORMS').val(), 4); + }); + + test("Init formset table", function() { + var html = $('#qunit-fixture'); + var table = html.find('table'); + + watcher.formset_table.init('flavors', '', 'Add row'); + equal(table.find('tfoot tr a').html(), 'Add row'); + }); + + test("Init formset table -- no add", function() { + var html = $('#qunit-fixture'); + var table = html.find('table'); + + watcher.formset_table.init('flavors', '', ''); + equal(table.find('tfoot tr a').length, 0); + }); +}); diff --git a/watcher_dashboard/templates/client_side/_modal_chart.html b/watcher_dashboard/templates/client_side/_modal_chart.html new file mode 100644 index 0000000..e26a7d4 --- /dev/null +++ b/watcher_dashboard/templates/client_side/_modal_chart.html @@ -0,0 +1,19 @@ +{% extends "horizon/client_side/template.html" %} +{% load horizon %} + +{% block id %}modal_chart_template{% endblock %} + +{% block template %} +{% jstemplate %} +
+ + +
+{% endjstemplate %} +{% endblock %} diff --git a/watcher_dashboard/templates/client_side/templates.html b/watcher_dashboard/templates/client_side/templates.html new file mode 100644 index 0000000..c164658 --- /dev/null +++ b/watcher_dashboard/templates/client_side/templates.html @@ -0,0 +1 @@ +{% include "client_side/_modal_chart.html" %} diff --git a/watcher_dashboard/templates/formset_table/_row.html b/watcher_dashboard/templates/formset_table/_row.html new file mode 100644 index 0000000..5409a0b --- /dev/null +++ b/watcher_dashboard/templates/formset_table/_row.html @@ -0,0 +1,24 @@ + + {% for cell in row %} + + {% if cell.field %} + {{ cell.field }} + {% else %} + {%if cell.wrap_list %}
    {% endif %}{{ cell.value }}{%if cell.wrap_list %}
{% endif %} + {% endif %} + {% if forloop.first %} + {% for field in row.form.hidden_fields %} + {{ field }} + {% for error in field.errors %} + {{ field.name }}: {{ error }} + {% endfor %} + {% endfor %} + {% if row.form.non_field_errors %} +
+ {{ row.form.non_field_errors }} +
+ {% endif %} + {% endif %} + + {% endfor %} + diff --git a/watcher_dashboard/templates/formset_table/_table.html b/watcher_dashboard/templates/formset_table/_table.html new file mode 100644 index 0000000..b63d020 --- /dev/null +++ b/watcher_dashboard/templates/formset_table/_table.html @@ -0,0 +1,43 @@ +{% extends 'horizon/common/_data_table.html' %} +{% load i18n %} + +{% block table_columns %} + {% if not table.is_browser_table %} + + {% for column in columns %} + {{ column }} + {% endfor %} + + {% endif %} +{% endblock table_columns %} + +{% block table %} + {% with table.get_formset as formset %} + {{ formset.management_form }} + {% if formset.non_field_errors %} +
+ {{ formset.non_field_errors }} +
+ {% endif %} + {% endwith %} + {{ block.super }} + + +{% endblock table %} diff --git a/watcher_dashboard/templates/formset_table/menu_formset.html b/watcher_dashboard/templates/formset_table/menu_formset.html new file mode 100644 index 0000000..68bc808 --- /dev/null +++ b/watcher_dashboard/templates/formset_table/menu_formset.html @@ -0,0 +1,44 @@ +{% load i18n %} +{{ formset.management_form }} +{% for error in formset.non_form_errors %} +
{{ error }}
+{% endfor %} +
+
+
+ + + + + + +

Nodes to register

+
+
+ {% include 'infra_optim/nodes/_upload.html' with form=upload_form %} +
+ +
+
+
+ {% for form in formset %} + {% include form_template with form=form active=forloop.first %} + {% endfor %} +
+
+
+ diff --git a/watcher_dashboard/templates/horizon/common/_items_count_domain_page_header.html b/watcher_dashboard/templates/horizon/common/_items_count_domain_page_header.html new file mode 100644 index 0000000..7cbb55d --- /dev/null +++ b/watcher_dashboard/templates/horizon/common/_items_count_domain_page_header.html @@ -0,0 +1,17 @@ +{% load i18n %} +{% block page_header %} + +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/_fullscreen_workflow.html b/watcher_dashboard/templates/infra_optim/_fullscreen_workflow.html new file mode 100644 index 0000000..f7870a1 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/_fullscreen_workflow.html @@ -0,0 +1,37 @@ +{% load i18n %} +{% with workflow.get_entry_point as entry_point %} +
+
+ {% csrf_token %} + {% if REDIRECT_URL %}{% endif %} +
+
+ {% block workflow-buttons %} + + {% endblock %} +
+ {% block workflow-body %} + +
+ {% for step in workflow.steps %} +
+ {{ step.render }} +
+ {% if not forloop.last %} + + {% endif %} + {% endfor %} +
+ {% endblock %} +
+
+
+{% endwith %} diff --git a/watcher_dashboard/templates/infra_optim/_fullscreen_workflow_base.html b/watcher_dashboard/templates/infra_optim/_fullscreen_workflow_base.html new file mode 100644 index 0000000..a7a5638 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/_fullscreen_workflow_base.html @@ -0,0 +1,11 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans workflow.name %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=workflow.name %} +{% endblock page_header %} + +{% block main %} + {% include 'infra_optim/_fullscreen_workflow.html' %} +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/_performance_chart.html b/watcher_dashboard/templates/infra_optim/_performance_chart.html new file mode 100644 index 0000000..f380671 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/_performance_chart.html @@ -0,0 +1,16 @@ +

{{ label }}

+
+
+
+
+
+
+
+
+
+
diff --git a/watcher_dashboard/templates/infra_optim/_performance_chart_box.html b/watcher_dashboard/templates/infra_optim/_performance_chart_box.html new file mode 100644 index 0000000..9ef94d1 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/_performance_chart_box.html @@ -0,0 +1,63 @@ +{% load i18n %} +{% load url from future%} + +{% if meter_conf %} +
+
+
+
+
+
+
+ +
+
+
+
+
{% trans "From" %}
+ +
+
+
+
+
{% trans "To" %}
+ +
+
+
+
+
+
+ +
+ {% for meter_label, url_part, y_max in meter_conf %} +
+ {% include "infra_optim/_performance_chart.html" with label=meter_label y_max=y_max url=node_perf_url|add:"?"|add:url_part only %} +
+ {% endfor %} +
+{% else %} +

{% trans 'Metering service is not enabled.' %}

+{% endif %} diff --git a/watcher_dashboard/templates/infra_optim/_top_5_box.html b/watcher_dashboard/templates/infra_optim/_top_5_box.html new file mode 100644 index 0000000..458caec --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/_top_5_box.html @@ -0,0 +1,8 @@ +
+
{% include "infra_optim/_top_5_chart.html" with top_5=top_5.fan%}
+
{% include "infra_optim/_top_5_chart.html" with top_5=top_5.voltage %}
+
{% include "infra_optim/_top_5_chart.html" with top_5=top_5.temperature%}
+
+
+
{% include "infra_optim/_top_5_chart.html" with top_5=top_5.current %}
+
diff --git a/watcher_dashboard/templates/infra_optim/_top_5_chart.html b/watcher_dashboard/templates/infra_optim/_top_5_chart.html new file mode 100644 index 0000000..bf383c6 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/_top_5_chart.html @@ -0,0 +1,29 @@ +{% load i18n %} +{% load chart_helpers %} + +

+ {% trans 'Top 5 Nodes' %} ({{ top_5.label }}): +

+{% if top_5.data %} + + {% for d in top_5.data %} + + + + + + {% endfor %} +
+ + {{ d.node_uuid|truncatechars:6 }} + + + {{ d.value}} {{ top_5.unit }} + + {%if d.direction %} + + {% endif %} +
+{% else %} +{% trans 'No data available.' %} +{% endif %} diff --git a/watcher_dashboard/templates/infra_optim/_workflow_base.html b/watcher_dashboard/templates/infra_optim/_workflow_base.html new file mode 100644 index 0000000..385e029 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/_workflow_base.html @@ -0,0 +1,11 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans workflow.name %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=workflow.name %} +{% endblock page_header %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/action_plans/_details_overview.html b/watcher_dashboard/templates/infra_optim/action_plans/_details_overview.html new file mode 100644 index 0000000..e3e33b4 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/action_plans/_details_overview.html @@ -0,0 +1,20 @@ +{% load i18n sizeformat %} +{% load url from future %} + +

{% trans "Action Plan Overview" %}

+ +
+
+
{% trans "ID" %}
+
{{ action_plan.uuid|default:"—" }}
+ {% url 'horizon:admin:audits:detail' action_plan.audit_uuid as audit_url %} +
{% trans "Audit ID" %}
+
{{ action_plan.audit_uuid|default:_("—") }}
+
{% trans "State" %}
+
{{ action_plan.state|default:"—" }}
+
{% trans "Created At" %}
+
{{ action_plan.created_at|default:"—" }}
+
{% trans "Update At" %}
+
{{ action_plan.updated_at|default:"—" }}
+
+
diff --git a/watcher_dashboard/templates/infra_optim/action_plans/create.html b/watcher_dashboard/templates/infra_optim/action_plans/create.html new file mode 100644 index 0000000..1fe76ab --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/action_plans/create.html @@ -0,0 +1,11 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Flavor" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Create Action Plan") %} +{% endblock page_header %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/action_plans/details.html b/watcher_dashboard/templates/infra_optim/action_plans/details.html new file mode 100644 index 0000000..89384ca --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/action_plans/details.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Action Plan Details"%}{% endblock %} + +{% block main %} +
+
+ {% include "infra_optim/action_plans/_details_overview.html" %} +
+
+ {{ wactions_table.render }} +
+
+
+{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/action_plans/index.html b/watcher_dashboard/templates/infra_optim/action_plans/index.html new file mode 100644 index 0000000..2b4ed81 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/action_plans/index.html @@ -0,0 +1,14 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% load url from future %} +{% block title %}{% trans 'Action Plans' %}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_items_count_domain_page_header.html' with title=_('Action Plans') items_count=action_plans_count %} +{% endblock page_header %} + +{% block main %} +
+ {{ action_plans_table.render }} +
+{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/actions/details.html b/watcher_dashboard/templates/infra_optim/actions/details.html new file mode 100644 index 0000000..840a475 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/actions/details.html @@ -0,0 +1,38 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans 'Actions: ' %}{{ action.uuid }}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_page_header.html' with title=_('Actions: ')|add:action.uuid %} +{% endblock page_header %} + +{% block main %} +
+
+

{% trans "Audit Info" %}

+
+
{% trans "ID" %}
+
{{ action.uuid|default:"—" }}
+
{% trans "Type" %}
+
{{ action.type|default:"—" }}
+ {% url 'horizon:admin:action_templates:detail' action.action_template_uuid as action_template_url %} +
{% trans "Audit Template" %}
+
{{ action.action_template_uuid|default:_("—") }}
+
{% trans "State" %}
+
{{ action.state|default:"—" }}
+
{% trans "Deadline" %}
+
{{ action.deadline|default:"—" }}
+
{% trans "Created At" %}
+
{{ action.created_at|default:"—" }}
+
{% trans "Update At" %}
+
{{ action.updated_at|default:"—" }}
+
+
+
+
+
+ {{ table.render }} +
+
+ +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/actions/index.html b/watcher_dashboard/templates/infra_optim/actions/index.html new file mode 100644 index 0000000..a7d7b1c --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/actions/index.html @@ -0,0 +1,14 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% load url from future %} +{% block title %}{% trans 'Actions' %}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_items_count_domain_page_header.html' with title=_('Actions') items_count=actions_count %} +{% endblock page_header %} + +{% block main %} +
+ {{ wactions_table.render }} +
+{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/actions_history/details.html b/watcher_dashboard/templates/infra_optim/actions_history/details.html new file mode 100644 index 0000000..c49ff64 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/actions_history/details.html @@ -0,0 +1,31 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans 'Audits: ' %}{{ audits.name }}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_page_header.html' with title=_('Audits: ')|add:audits.name %} +{% endblock page_header %} + +{% block main %} +
+
+

{% trans "Hardware Info" %}

+
+
{% trans "Severity" %}
+
{{ audits.cpu_arch|default:"—" }}
+
{% trans "CPUs" %}
+
{{ audits.vcpus|default:"—" }}
+
{% trans "Memory" %}
+
{{ audits.ram_bytes|filesizeformat|default:"—" }}
+
{% trans "Disk" %}
+
{{ audits.disk_bytes|filesizeformat|default:"—" }}
+
+
+
+
+
+ {{ table.render }} +
+
+ +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/actions_history/index.html b/watcher_dashboard/templates/infra_optim/actions_history/index.html new file mode 100644 index 0000000..767d250 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/actions_history/index.html @@ -0,0 +1,35 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% load url from future %} +{% block title %}{% trans 'Audits' %}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_items_count_domain_page_header.html' with title=_('Audits') items_count=flavors_count %} +{% endblock page_header %} + +{% block main %} +{% if suggested_flavors_count %} + + + + + + + +
+ {{ suggested_flavors_count }}× Suggested Flavor + + + + +
+ +
+ {{ suggested_flavors_table.render }} +
+{% endif %} + +
+ {{ audits_table.render }} +
+{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/audit_templates/_create.html b/watcher_dashboard/templates/infra_optim/audit_templates/_create.html new file mode 100644 index 0000000..b6ec4d2 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audit_templates/_create.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% trans "Creates an audit template with specified parameters." %}

+{% endblock %} \ No newline at end of file diff --git a/watcher_dashboard/templates/infra_optim/audit_templates/create.html b/watcher_dashboard/templates/infra_optim/audit_templates/create.html new file mode 100644 index 0000000..e823ab5 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audit_templates/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Template" %}{% endblock %} + +{% block main %} + {% include 'infra_optim/audit_templates/_create.html' %} +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/audit_templates/details.html b/watcher_dashboard/templates/infra_optim/audit_templates/details.html new file mode 100644 index 0000000..193f748 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audit_templates/details.html @@ -0,0 +1,35 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans 'Audit Templates: ' %}{{ audit_template.name }}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_page_header.html' with title=_('Audit Templates: ')|add:audit_template.name %} +{% endblock page_header %} + +{% block main %} +
+
+

{% trans "Audit Template Info" %}

+
+
{% trans "Name" %}
+
{{ audit_template.name|default:"—" }}
+
{% trans "Id" %}
+
{{ audit_template.uuid|default:"—" }}
+
{% trans "Goal" %}
+
{{ audit_template.goal|default:"—" }}
+
{% trans "Created At" %}
+
{{ audit_template.created_at|default:"—" }}
+
{% trans "Update At" %}
+
{{ audit_template.updated_at|default:"—" }}
+
{% trans "Deleted At" %}
+
{{ audit_template.deleted_at|default:"—" }}
+
+
+
+
+
+ {{ table.render }} +
+
+ +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/audit_templates/index.html b/watcher_dashboard/templates/infra_optim/audit_templates/index.html new file mode 100644 index 0000000..2514cb0 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audit_templates/index.html @@ -0,0 +1,14 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% load url from future %} +{% block title %}{% trans 'Audit Templates' %}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_page_header.html' with title=_('Audit Templates') items_count=audit_templates_count %} +{% endblock page_header %} + +{% block main %} +
+ {{ audit_templates_table.render }} +
+{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/audits/_create.html b/watcher_dashboard/templates/infra_optim/audits/_create.html new file mode 100644 index 0000000..a507b78 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audits/_create.html @@ -0,0 +1,7 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

{% trans "Creates a audit with specified parameters." %}

+{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/audits/create.html b/watcher_dashboard/templates/infra_optim/audits/create.html new file mode 100644 index 0000000..a410569 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audits/create.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create Audit" %}{% endblock %} + +{% block main %} + {% include 'infra_optim/audits/_create.html' %} +{% endblock %} \ No newline at end of file diff --git a/watcher_dashboard/templates/infra_optim/audits/details.html b/watcher_dashboard/templates/infra_optim/audits/details.html new file mode 100644 index 0000000..ac213c3 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audits/details.html @@ -0,0 +1,38 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans 'Audits: ' %}{{ audit.uuid }}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_page_header.html' with title=_('Audits: ')|add:audit.uuid %} +{% endblock page_header %} + +{% block main %} +
+
+

{% trans "Audit Info" %}

+
+
{% trans "ID" %}
+
{{ audit.uuid|default:"—" }}
+
{% trans "Type" %}
+
{{ audit.type|default:"—" }}
+ {% url 'horizon:admin:audit_templates:detail' audit.audit_template_uuid as audit_template_url %} +
{% trans "Audit Template" %}
+
{{ audit.audit_template_uuid|default:_("—") }}
+
{% trans "State" %}
+
{{ audit.state|default:"—" }}
+
{% trans "Deadline" %}
+
{{ audit.deadline|default:"—" }}
+
{% trans "Created At" %}
+
{{ audit.created_at|default:"—" }}
+
{% trans "Update At" %}
+
{{ audit.updated_at|default:"—" }}
+
+
+
+
+
+ {{ table.render }} +
+
+ +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/audits/index.html b/watcher_dashboard/templates/infra_optim/audits/index.html new file mode 100644 index 0000000..8153b41 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/audits/index.html @@ -0,0 +1,14 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% load url from future %} +{% block title %}{% trans 'Audits' %}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_page_header.html' with title=_('Audits') items_count=audits_count %} +{% endblock page_header %} + +{% block main %} +
+ {{ audits_table.render }} +
+{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/base.html b/watcher_dashboard/templates/infra_optim/base.html new file mode 100644 index 0000000..6492a27 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/base.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block css %} + {{block.super}} + + {% load compress %} + {% compress css %} + + + + {% endcompress %} +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/base_detail.html b/watcher_dashboard/templates/infra_optim/base_detail.html new file mode 100644 index 0000000..db7ccc1 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/base_detail.html @@ -0,0 +1,27 @@ +{% extends 'infra_optim/base.html' %} + +{% block main %} + +
+
+ {% block breadcrumbs %}{% endblock %} + +
+ {% block actions %}{% endblock %} +
+ +

{% block name %}{% endblock %}

+
+
+ +
+
+
+ {% block overall_usage %}{% endblock %} +
+ + {{ tab_group.render }} +
+
+ +{% endblock %} diff --git a/watcher_dashboard/templates/infra_optim/header_actions.html b/watcher_dashboard/templates/infra_optim/header_actions.html new file mode 100644 index 0000000..6544f29 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/header_actions.html @@ -0,0 +1,10 @@ + diff --git a/watcher_dashboard/templates/infra_optim/logs/index.html b/watcher_dashboard/templates/infra_optim/logs/index.html new file mode 100644 index 0000000..2b05d01 --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/logs/index.html @@ -0,0 +1,17 @@ +{% extends 'infra_optim/base.html' %} +{% load i18n %} +{% block title %}{% trans 'Logs' %}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_items_count_domain_page_header.html' with title=_('Logs') items_count=flavors_count %} +{% endblock page_header %} + +{% block main %} +
+
+ {{ table.render }} +
+
+ +{% endblock %} + diff --git a/watcher_dashboard/templates/infra_optim/qunit.html b/watcher_dashboard/templates/infra_optim/qunit.html new file mode 100644 index 0000000..dd9724b --- /dev/null +++ b/watcher_dashboard/templates/infra_optim/qunit.html @@ -0,0 +1,189 @@ + + + + + Watcher QUnit Test Suite + + + + {% include "horizon/_conf.html" %} + + {% comment %}Load test modules here.{% endcomment %} + + {% comment %}End test modules.{% endcomment %} + + {% include "horizon/_scripts.html" %} + {% include "infra_optim/_scripts.html" %} + + +

Watcher JavaScript Tests

+

+
+

+
    +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +

    Flavors

    + +
    +
    Flavor NameVCPURAM (MB)Root Disk (GB)Ephemeral Disk + (GB)Swap Disk + (MB)Max. + VMs + Delete
    -
    -
    -
    Displaying 3 + items
    +
    + +
    + + diff --git a/watcher_dashboard/test/__init__.py b/watcher_dashboard/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/api_tests/__init__.py b/watcher_dashboard/test/api_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/api_tests/watcher_tests.py b/watcher_dashboard/test/api_tests/watcher_tests.py new file mode 100644 index 0000000..d9601ba --- /dev/null +++ b/watcher_dashboard/test/api_tests/watcher_tests.py @@ -0,0 +1,243 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from watcher_dashboard import api +from watcher_dashboard.test import helpers as test + + +class WatcherAPITests(test.APITestCase): + + def test_audit_template_list(self): + audit_templates = {'audit_templates': self.api_audit_templates.list()} + watcherclient = self.stub_watcherclient() + + watcherclient.audit_template = self.mox.CreateMockAnything() + watcherclient.audit_template.list(name=None).AndReturn(audit_templates) + self.mox.ReplayAll() + + ret_val = api.watcher.AuditTemplate.list(self.request, filter=None) + for n in ret_val: + self.assertTrue(type(ret_val), 'dict') + + def test_audit_template_list_with_filters(self): + search_opts = 'Audit Template 1' + audit_templates = self.api_audit_templates.filter(name=search_opts) + watcherclient = self.stub_watcherclient() + + watcherclient.audit_template = self.mox.CreateMockAnything() + + watcherclient.audit_template.list(name=search_opts)\ + .AndReturn(audit_templates) + self.mox.ReplayAll() + + ret_val = api.watcher.AuditTemplate\ + .list(self.request, filter=search_opts) + for n in ret_val: + self.assertTrue(type(ret_val), 'dict') + self.assertEqual(ret_val, audit_templates) + + def test_audit_template_get(self): + audit_template = {'audit_template': self.api_audit_templates.first()} + audit_template_id = self.api_audit_templates.first()['uuid'] + + watcherclient = self.stub_watcherclient() + watcherclient.audit_template = self.mox.CreateMockAnything() + watcherclient.audit_template.get( + audit_template_id=audit_template_id).AndReturn(audit_template) + self.mox.ReplayAll() + + ret_val = api.watcher.AuditTemplate.get(self.request, + audit_template_id) + self.assertTrue(type(ret_val), 'dict') + + def test_audit_template_create(self): + audit_template = {'audit_template': self.api_audit_templates.first()} + name = self.api_audit_templates.first()['name'] + goal = self.api_audit_templates.first()['goal'] + description = self.api_audit_templates.first()['description'] + host_aggregate = self.api_audit_templates.first()['host_aggregate'] + + watcherclient = self.stub_watcherclient() + watcherclient.audit_template = self.mox.CreateMockAnything() + watcherclient.audit_template.create( + name=name, + goal=goal, + description=description, + host_aggregate=host_aggregate).AndReturn(audit_template) + self.mox.ReplayAll() + + ret_val = api.watcher.AuditTemplate.create( + self.request, name, goal, description, host_aggregate) + self.assertTrue(type(ret_val), 'dict') + + def test_audit_template_patch(self): + audit_template = {'audit_template': self.api_audit_templates.first()} + audit_template_id = self.api_audit_templates.first()['uuid'] + form_data = {'name': 'new Audit Template 1'} + + watcherclient = self.stub_watcherclient() + watcherclient.audit_template = self.mox.CreateMockAnything() + watcherclient.audit_template.patch( + audit_template_id, + [{'name': 'name', 'value': 'new Audit Template 1'}] + ).AndReturn(audit_template) + self.mox.ReplayAll() + + ret_val = api.watcher.AuditTemplate.patch( + self.request, audit_template_id, + form_data) + self.assertTrue(type(ret_val), 'dict') + + def test_audit_template_delete(self): + audit_template_list = self.api_audit_templates.list() + audit_template_id = self.api_audit_templates.first()['uuid'] + deleted_at_list = self.api_audit_templates.delete() + + watcherclient = self.stub_watcherclient() + watcherclient.audit_template = self.mox.CreateMockAnything() + watcherclient.audit_template.delete( + audit_template_id=audit_template_id) + self.mox.ReplayAll() + api.watcher.AuditTemplate.delete(self.request, + audit_template_id) + self.assertEqual(audit_template_list, deleted_at_list) + self.assertEqual(len(audit_template_list), len(deleted_at_list)) + + def test_audit_list(self): + audits = {'audits': self.api_audits.list()} + + watcherclient = self.stub_watcherclient() + + watcherclient.audit = self.mox.CreateMockAnything() + watcherclient.audit.list(audit_template=None).AndReturn(audits) + self.mox.ReplayAll() + + ret_val = api.watcher.Audit.list( + self.request, + audit_template_filter=None) + for n in ret_val: + self.assertIsInstance(n, api.watcher.Audit) + + def test_audit_get(self): + audit = {'audit': self.api_audits.first()} + audit_id = self.api_audits.first()['id'] + + watcherclient = self.stub_watcherclient() + watcherclient.audit = self.mox.CreateMockAnything() + watcherclient.audit.get( + audit_id=audit_id).AndReturn(audit) + self.mox.ReplayAll() + + ret_val = api.watcher.Audit.get(self.request, audit_id) + self.assertIsInstance(ret_val, api.watcher.Audit) + + def test_audit_create(self): + audit = {'audit': self.api_audits.first()} + audit_template_id = self.api_audit_templates.first()['uuid'] + + deadline = self.api_audits.first()['deadline'] + _type = self.api_audits.first()['type'] + audit_template_uuid = audit_template_id + + watcherclient = self.stub_watcherclient() + watcherclient.audit = self.mox.CreateMockAnything() + watcherclient.audit.create( + audit_template_uuid=audit_template_uuid, + type=_type, + deadline=deadline).AndReturn(audit) + self.mox.ReplayAll() + + ret_val = api.watcher.Audit.create( + self.request, audit_template_uuid, _type, deadline) + self.assertIsInstance(ret_val, api.watcher.Audit) + + def test_audit_delete(self): + audit_id = self.api_audits.first()['id'] + + watcherclient = self.stub_watcherclient() + watcherclient.audit = self.mox.CreateMockAnything() + watcherclient.audit.delete( + audit_id=audit_id) + self.mox.ReplayAll() + + api.watcher.Audit.delete(self.request, audit_id) + + def test_action_plan_list(self): + action_plans = {'action_plans': self.api_action_plans.list()} + + watcherclient = self.stub_watcherclient() + + watcherclient.action_plan = self.mox.CreateMockAnything() + watcherclient.action_plan.list(audit=None).AndReturn(action_plans) + self.mox.ReplayAll() + + ret_val = api.watcher.ActionPlan.list( + self.request, + audit_filter=None) + for n in ret_val: + self.assertIsInstance(n, api.watcher.ActionPlan) + + def test_action_plan_get(self): + action_plan = {'action_plan': self.api_action_plans.first()} + action_plan_id = self.api_action_plans.first()['id'] + + watcherclient = self.stub_watcherclient() + watcherclient.action_plan = self.mox.CreateMockAnything() + watcherclient.action_plan.get( + action_plan_id=action_plan_id).AndReturn(action_plan) + self.mox.ReplayAll() + + ret_val = api.watcher.ActionPlan.get(self.request, action_plan_id) + self.assertIsInstance(ret_val, api.watcher.ActionPlan) + + def test_action_plan_start(self): + action_plan_id = self.api_action_plans.first()['id'] + patch = [] + patch.append({'path': '/state', 'value': 'TRIGGERED', 'op': 'replace'}) + + watcherclient = self.stub_watcherclient() + watcherclient.action_plan = self.mox.CreateMockAnything() + watcherclient.action_plan.update(action_plan_id, patch) + self.mox.ReplayAll() + + api.watcher.ActionPlan.start(self.request, action_plan_id) + + def test_action_plan_delete(self): + action_plan_id = self.api_action_plans.first()['id'] + + watcherclient = self.stub_watcherclient() + watcherclient.action_plan = self.mox.CreateMockAnything() + watcherclient.action_plan.delete( + action_plan_id=action_plan_id) + self.mox.ReplayAll() + + api.watcher.ActionPlan.delete(self.request, action_plan_id) + + def test_action_list(self): + actions = {'actions': self.api_actions.list()} + watcherclient = self.stub_watcherclient() + + watcherclient.action = self.mox.CreateMockAnything() + watcherclient.action.list( + action_plan=None, detail=True).AndReturn(actions) + self.mox.ReplayAll() + + ret_val = api.watcher.Action.list( + self.request, + action_plan_filter=None) + for n in ret_val: + self.assertIsInstance(n, api.watcher.Action) diff --git a/watcher_dashboard/test/formset_table_tests.py b/watcher_dashboard/test/formset_table_tests.py new file mode 100644 index 0000000..3ee89fd --- /dev/null +++ b/watcher_dashboard/test/formset_table_tests.py @@ -0,0 +1,60 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import django.forms +from horizon import tables +from horizon.tables import formset as hformset + +from watcher_dashboard.test import helpers as test + + +class FormsetTableTests(test.TestCase): + + def test_populate(self): + """Create a FormsetDataTable and populate it with data.""" + + class TableObj(object): + pass + + obj = TableObj() + obj.name = 'test object' + obj.value = 42 + obj.id = 4 + + class TableForm(django.forms.Form): + name = django.forms.CharField() + value = django.forms.IntegerField() + + TableFormset = django.forms.formsets.formset_factory(TableForm, + extra=0) + + class Table(hformset.FormsetDataTable): + formset_class = TableFormset + + name = tables.Column('name') + value = tables.Column('value') + + class Meta(object): + name = 'table' + + table = Table(self.request) + table.data = [obj] + formset = table.get_formset() + self.assertEqual(len(formset), 1) + form = formset[0] + form_data = form.initial + self.assertEqual(form_data['name'], 'test object') + self.assertEqual(form_data['value'], 42) diff --git a/watcher_dashboard/test/helpers.py b/watcher_dashboard/test/helpers.py new file mode 100644 index 0000000..367092b --- /dev/null +++ b/watcher_dashboard/test/helpers.py @@ -0,0 +1,513 @@ +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import unicode_literals + +import collections +import copy +from functools import wraps +import json +import os +import unittest + +from django.conf import settings +from django.contrib.messages.storage import default_storage # noqa +from django.core.handlers import wsgi +from django.core import urlresolvers +from django.test.client import RequestFactory # noqa +from django.test import utils as django_test_utils +from django.utils.importlib import import_module # noqa +from horizon import base +from horizon import conf +from horizon.test import helpers as horizon_helpers +import httplib2 +from keystoneclient.v2_0 import client as keystone_client +import mock +from mox3 import mox +from openstack_auth import user +from openstack_auth import utils +from openstack_dashboard import api +from openstack_dashboard import context_processors +from watcherclient import client as watcherclient + +from watcher_dashboard import api as wapi +from watcher_dashboard.test.test_data import utils as test_utils + + +# Makes output of failing mox tests much easier to read. +wsgi.WSGIRequest.__repr__ = lambda self: "" + + +def create_stubs(stubs_to_create={}): + """decorator to simplify setting up multiple stubs at once via mox + + :param stubs_to_create: methods to stub in one or more modules + :type stubs_to_create: dict + + The keys are python paths to the module containing the methods to mock. + + To mock a method in openstack_dashboard/api/nova.py, the key is:: + + api.nova + + The values are either a tuple of list of methods to mock in the module + indicated by the key. + + For example:: + + ('server_list',) + -or- + ('flavor_list', 'server_list',) + -or- + ['flavor_list', 'server_list'] + + Additionally, multiple modules can be mocked at once:: + + { + api.nova: ('flavor_list', 'server_list'), + api.glance: ('image_list_detailed',), + } + + """ + + if not isinstance(stubs_to_create, dict): + raise TypeError("create_stub must be passed a dict, but a %s was " + "given." % type(stubs_to_create).__name__) + + def inner_stub_out(fn): + @wraps(fn) + def instance_stub_out(self, *args, **kwargs): + for key in stubs_to_create: + if not (isinstance(stubs_to_create[key], tuple) or + isinstance(stubs_to_create[key], list)): + raise TypeError("The values of the create_stub " + "dict must be lists or tuples, but " + "is a %s." + % type(stubs_to_create[key]).__name__) + + for value in stubs_to_create[key]: + self.mox.StubOutWithMock(key, value) + return fn(self, *args, **kwargs) + return instance_stub_out + return inner_stub_out + + +class RequestFactoryWithMessages(RequestFactory): + def get(self, *args, **kwargs): + req = super(RequestFactoryWithMessages, self).get(*args, **kwargs) + req.user = utils.get_user(req) + req.session = [] + req._messages = default_storage(req) + return req + + def post(self, *args, **kwargs): + req = super(RequestFactoryWithMessages, self).post(*args, **kwargs) + req.user = utils.get_user(req) + req.session = [] + req._messages = default_storage(req) + return req + + +@unittest.skipIf(os.environ.get('SKIP_UNITTESTS', False), + "The SKIP_UNITTESTS env variable is set.") +class TestCase(horizon_helpers.TestCase): + """Specialized base test case class for Horizon. + + It gives access to numerous additional features: + + * A full suite of test data through various attached objects and + managers (e.g. ``self.servers``, ``self.user``, etc.). See the + docs for + :class:`~openstack_dashboard.test.test_data.utils.TestData` + for more information. + * The ``mox`` mocking framework via ``self.mox``. + * A set of request context data via ``self.context``. + * A ``RequestFactory`` class which supports Django's ``contrib.messages`` + framework via ``self.factory``. + * A ready-to-go request object via ``self.request``. + * The ability to override specific time data controls for easier testing. + * Several handy additional assertion methods. + """ + def setUp(self): + def fake_conn_request(*args, **kwargs): + raise Exception("An external URI request tried to escape through " + "an httplib2 client. Args: %s, kwargs: %s" + % (args, kwargs)) + + self._real_conn_request = httplib2.Http._conn_request + httplib2.Http._conn_request = fake_conn_request + + self._real_context_processor = context_processors.openstack + context_processors.openstack = lambda request: self.context + + self.patchers = {} + self.add_panel_mocks() + + super(TestCase, self).setUp() + + def _setup_test_data(self): + super(TestCase, self)._setup_test_data() + test_utils.load_test_data(self) + self.context = {'authorized_tenants': self.tenants.list()} + + def _setup_factory(self): + # For some magical reason we need a copy of this here. + self.factory = RequestFactoryWithMessages() + + def _setup_user(self): + self._real_get_user = utils.get_user + tenants = self.context['authorized_tenants'] + self.set_active_user( + id=self.user.id, + token=self.token, + username=self.user.name, + domain_id=self.domain.id, + tenant_id=self.tenant.id, + service_catalog=self.service_catalog, + authorized_tenants=tenants) + + def _setup_request(self): + super(TestCase, self)._setup_request() + self.request.session['token'] = self.token.id + + def add_panel_mocks(self): + """Global mocks on panels that get called on all views.""" + self.patchers['aggregates'] = mock.patch( + 'openstack_dashboard.dashboards.admin' + '.aggregates.panel.Aggregates.can_access', + mock.Mock(return_value=True)) + self.patchers['aggregates'].start() + + def tearDown(self): + httplib2.Http._conn_request = self._real_conn_request + context_processors.openstack = self._real_context_processor + utils.get_user = self._real_get_user + mock.patch.stopall() + super(TestCase, self).tearDown() + + def set_active_user(self, id=None, token=None, username=None, + tenant_id=None, service_catalog=None, tenant_name=None, + roles=None, authorized_tenants=None, enabled=True, + domain_id=None): + def get_user(request): + return user.User(id=id, + token=token, + user=username, + domain_id=domain_id, + tenant_id=tenant_id, + service_catalog=service_catalog, + roles=roles, + enabled=enabled, + authorized_tenants=authorized_tenants, + endpoint=settings.OPENSTACK_KEYSTONE_URL) + utils.get_user = get_user + + def assertRedirectsNoFollow(self, response, expected_url): + """Check for redirect. + + Asserts that the given response issued a 302 redirect without + processing the view which is redirected to. + """ + assert (response.status_code / 100 == 3), \ + "The response did not return a redirect." + self.assertEqual(response._headers.get('location', None), + ('Location', settings.TESTSERVER + expected_url)) + self.assertEqual(response.status_code, 302) + + def assertNoFormErrors(self, response, context_name="form"): + """Checks for no form errors. + + Asserts that the response either does not contain a form in its + context, or that if it does, that form has no errors. + """ + context = getattr(response, "context", {}) + if not context or context_name not in context: + return True + errors = response.context[context_name]._errors + assert len(errors) == 0, \ + "Unexpected errors were found on the form: %s" % errors + + def assertFormErrors(self, response, count=0, message=None, + context_name="form"): + """Check for form errors. + + Asserts that the response does contain a form in its + context, and that form has errors, if count were given, + it must match the exact numbers of errors + """ + context = getattr(response, "context", {}) + assert (context and context_name in context), \ + "The response did not contain a form." + errors = response.context[context_name]._errors + if count: + assert len(errors) == count, \ + "%d errors were found on the form, %d expected" % \ + (len(errors), count) + if message and message not in str(errors): + self.fail("Expected message not found, instead found: %s" + % ["%s: %s" % (key, [e for e in field_errors]) for + (key, field_errors) in errors.items()]) + else: + assert len(errors) > 0, "No errors were found on the form" + + def assertStatusCode(self, response, expected_code): + """Validates an expected status code. + + Matches camel case of other assert functions + """ + if response.status_code == expected_code: + return + self.fail('status code %r != %r: %s' % (response.status_code, + expected_code, + response.content)) + + def assertItemsCollectionEqual(self, response, items_list): + self.assertEqual(response.content, + '{"items": ' + json.dumps(items_list) + "}") + + @staticmethod + def mock_rest_request(**args): + mock_args = { + 'user.is_authenticated.return_value': True, + 'is_ajax.return_value': True, + 'policy.check.return_value': True, + 'body': '' + } + mock_args.update(args) + return mock.Mock(**mock_args) + + +class BaseAdminViewTests(TestCase): + """Sets an active user with the "admin" role. + + For testing admin-only views and functionality. + """ + def set_active_user(self, *args, **kwargs): + if "roles" not in kwargs: + kwargs['roles'] = [self.roles.admin._info] + super(BaseAdminViewTests, self).set_active_user(*args, **kwargs) + + def set_session_values(self, **kwargs): + settings.SESSION_ENGINE = 'django.contrib.sessions.backends.file' + engine = import_module(settings.SESSION_ENGINE) + store = engine.SessionStore() + for key in kwargs: + store[key] = kwargs[key] + self.request.session[key] = kwargs[key] + store.save() + self.session = store + self.client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key + + +class APITestCase(TestCase): + """Testing APIs. + + For use with tests which deal with the underlying clients rather than + stubbing out the openstack_dashboard.api.* methods. + """ + def setUp(self): + super(APITestCase, self).setUp() + utils.patch_middleware_get_user() + + def fake_keystoneclient(request, admin=False): + """Returns the stub keystoneclient. + + Only necessary because the function takes too many arguments to + conveniently be a lambda. + """ + return self.stub_keystoneclient() + + # Store the original clients + self._original_watcherclient = wapi.watcher.watcherclient + self._original_keystoneclient = api.keystone.keystoneclient + + # Replace the clients with our stubs. + wapi.watcher.watcherclient = lambda request: self.stub_watcherclient() + api.keystone.keystoneclient = fake_keystoneclient + + def tearDown(self): + super(APITestCase, self).tearDown() + wapi.watcher.watcherclient = self._original_watcherclient + api.keystone.keystoneclient = self._original_keystoneclient + + def stub_keystoneclient(self): + if not hasattr(self, "keystoneclient"): + self.mox.StubOutWithMock(keystone_client, 'Client') + # NOTE(saschpe): Mock properties, MockObject.__init__ ignores them: + keystone_client.Client.auth_token = 'foo' + keystone_client.Client.service_catalog = None + keystone_client.Client.tenant_id = '1' + keystone_client.Client.tenant_name = 'tenant_1' + keystone_client.Client.management_url = "" + keystone_client.Client.__dir__ = lambda: [] + self.keystoneclient = self.mox.CreateMock(keystone_client.Client) + return self.keystoneclient + + def stub_watcherclient(self): + if not hasattr(self, "watcherclient"): + self.mox.StubOutWithMock(watcherclient, 'Client') + self.watcherclient = self.mox.CreateMock(watcherclient.Client) + return self.watcherclient + + +@unittest.skipUnless(os.environ.get('WITH_SELENIUM', False), + "The WITH_SELENIUM env variable is not set.") +class SeleniumTestCase(horizon_helpers.SeleniumTestCase): + + def setUp(self): + super(SeleniumTestCase, self).setUp() + + test_utils.load_test_data(self) + self.mox = mox.Mox() + + self._real_get_user = utils.get_user + self.set_active_user(id=self.user.id, + token=self.token, + username=self.user.name, + tenant_id=self.tenant.id, + service_catalog=self.service_catalog, + authorized_tenants=self.tenants.list()) + self.patchers = {} + self.patchers['aggregates'] = mock.patch( + 'openstack_dashboard.dashboards.admin' + '.aggregates.panel.Aggregates.can_access', + mock.Mock(return_value=True)) + self.patchers['aggregates'].start() + os.environ["HORIZON_TEST_RUN"] = "True" + + def tearDown(self): + super(SeleniumTestCase, self).tearDown() + self.mox.UnsetStubs() + utils.get_user = self._real_get_user + mock.patch.stopall() + self.mox.VerifyAll() + del os.environ["HORIZON_TEST_RUN"] + + def set_active_user(self, id=None, token=None, username=None, + tenant_id=None, service_catalog=None, tenant_name=None, + roles=None, authorized_tenants=None, enabled=True): + def get_user(request): + return user.User(id=id, + token=token, + user=username, + tenant_id=tenant_id, + service_catalog=service_catalog, + roles=roles, + enabled=enabled, + authorized_tenants=authorized_tenants, + endpoint=settings.OPENSTACK_KEYSTONE_URL) + utils.get_user = get_user + + +class SeleniumAdminTestCase(SeleniumTestCase): + """Version of AdminTestCase for Selenium. + + Sets an active user with the "admin" role for testing admin-only views and + functionality. + """ + def set_active_user(self, *args, **kwargs): + if "roles" not in kwargs: + kwargs['roles'] = [self.roles.admin._info] + super(SeleniumAdminTestCase, self).set_active_user(*args, **kwargs) + + +def my_custom_sort(flavor): + sort_order = { + 'm1.secret': 0, + 'm1.tiny': 1, + 'm1.massive': 2, + 'm1.metadata': 3, + } + return sort_order[flavor.name] + + +class PluginTestCase(TestCase): + """Test case for testing plugin system of Horizon. + + For use with tests which deal with the pluggable dashboard and panel + configuration, it takes care of backing up and restoring the Horizon + configuration. + """ + def setUp(self): + super(PluginTestCase, self).setUp() + self.old_horizon_config = conf.HORIZON_CONFIG + conf.HORIZON_CONFIG = conf.LazySettings() + base.Horizon._urls() + # Store our original dashboards + self._discovered_dashboards = base.Horizon._registry.keys() + # Gather up and store our original panels for each dashboard + self._discovered_panels = {} + for dash in self._discovered_dashboards: + panels = base.Horizon._registry[dash]._registry.keys() + self._discovered_panels[dash] = panels + + def tearDown(self): + super(PluginTestCase, self).tearDown() + conf.HORIZON_CONFIG = self.old_horizon_config + # Destroy our singleton and re-create it. + base.HorizonSite._instance = None + del base.Horizon + base.Horizon = base.HorizonSite() + # Reload the convenience references to Horizon stored in __init__ + reload(import_module("horizon")) + # Re-register our original dashboards and panels. + # This is necessary because autodiscovery only works on the first + # import, and calling reload introduces innumerable additional + # problems. Manual re-registration is the only good way for testing. + for dash in self._discovered_dashboards: + base.Horizon.register(dash) + for panel in self._discovered_panels[dash]: + dash.register(panel) + self._reload_urls() + + def _reload_urls(self): + """CLeans up URLs. + + Clears out the URL caches, reloads the root urls module, and + re-triggers the autodiscovery mechanism for Horizon. Allows URLs + to be re-calculated after registering new dashboards. Useful + only for testing and should never be used on a live site. + """ + urlresolvers.clear_url_caches() + reload(import_module(settings.ROOT_URLCONF)) + base.Horizon._urls() + + +class update_settings(django_test_utils.override_settings): + """override_settings which allows override an item in dict. + + django original override_settings replaces a dict completely, + however OpenStack dashboard setting has many dictionary configuration + and there are test case where we want to override only one item in + a dictionary and keep other items in the dictionary. + This version of override_settings allows this if keep_dict is True. + + If keep_dict False is specified, the original behavior of + Django override_settings is used. + """ + + def __init__(self, keep_dict=True, **kwargs): + if keep_dict: + for key, new_value in kwargs.items(): + value = getattr(settings, key, None) + if (isinstance(new_value, collections.Mapping) and + isinstance(value, collections.Mapping)): + copied = copy.copy(value) + copied.update(new_value) + kwargs[key] = copied + super(update_settings, self).__init__(**kwargs) diff --git a/watcher_dashboard/test/integration_tests/__init__.py b/watcher_dashboard/test/integration_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/integration_tests/horizon.conf b/watcher_dashboard/test/integration_tests/horizon.conf new file mode 100644 index 0000000..984c1c7 --- /dev/null +++ b/watcher_dashboard/test/integration_tests/horizon.conf @@ -0,0 +1,69 @@ +# +# Configuration filed based on Tempest's tempest.conf.sample +# + +[dashboard] +# Where the dashboard can be found (string value) +dashboard_url=http://localhost:8000/ + +# Dashboard help page url (string value) +help_url=http://docs.openstack.org/ + +[selenium] +# Timeout in seconds to wait for a page to become available +# (integer value) +page_timeout=30 + +# Output directory for screenshots. +# (string value) +screenshots_directory=integration_tests_screenshots + +# Implicit timeout to wait until element become available, +# this timeout is used for every find_element, find_elements call. +# (integer value) +implicit_wait=10 + +# Explicit timeout is used for long lasting operations, +# methods using explicit timeout are usually prefixed with 'wait', +# those methods ignore implicit_wait when looking up web elements. +# (integer value) +explicit_wait=300 + +[image] +# http accessible image (string value) +http_image=http://download.cirros-cloud.net/0.3.1/cirros-0.3.1-x86_64-uec.tar.gz + +[identity] +# Username to use for non-admin API requests. (string value) +username=demo + +# API key to use when authenticating. (string value) +password=secretadmin + +# Administrative Username to use for admin API requests. +# (string value) +admin_username=admin + +# API key to use when authenticating as admin. (string value) +admin_password=secretadmin + +[scenario] +# ssh username for image file (string value) +ssh_user=cirros + +[launch_instances] +#available zone to launch instances +available_zone=nova +#image_name to launch instances +image_name=cirros-0.3.4-x86_64-uec (24.0 MB) + +[volume] +volume_type=lvmdriver-1 +volume_size=1 + + +[plugin] + +is_plugin=true +plugin_page_path=watcher_dashboard.test.integration_tests.pages +plugin_page_structure={"Admin": {"Optimization": {"_": ["Audit Templates", "Audits", "Action Plans", "Actions"] } } } diff --git a/watcher_dashboard/test/integration_tests/pages/__init__.py b/watcher_dashboard/test/integration_tests/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/integration_tests/pages/admin/__init__.py b/watcher_dashboard/test/integration_tests/pages/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/integration_tests/pages/admin/optimization/__init__.py b/watcher_dashboard/test/integration_tests/pages/admin/optimization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/integration_tests/pages/admin/optimization/auditspage.py b/watcher_dashboard/test/integration_tests/pages/admin/optimization/auditspage.py new file mode 100644 index 0000000..6cf7692 --- /dev/null +++ b/watcher_dashboard/test/integration_tests/pages/admin/optimization/auditspage.py @@ -0,0 +1,82 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + +from openstack_dashboard.test.integration_tests.pages import basepage + + +class AuditsTable(tables.TableRegion): + + name = "audits" + + @tables.bind_table_action('launch_audit') + def launch_audit(self, launch_button): + launch_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('go_to_action_plan', primary=True) + def go_to_action_plan(self, goto_button): + goto_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + @tables.bind_row_action('go_to_audit_template') + def go_to_audit_template(self, goto_button): + goto_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class AuditsPage(basepage.BaseNavigationPage): + + DEFAULT_ID = "auto" + AUDIT_TABLE_NAME_COLUMN = 'name' + AUDIT_TABLE_TEMPLATE_COLUMN_INDEX = 1 + + def __init__(self, driver, conf): + super(AuditsPage, self).__init__(driver, conf) + self._page_title = "Audits" + + @property + def audits_table(self): + return AuditsTable(self.driver, self.conf) + + def _get_audit_row(self, name): + return self.audits_table.get_row(self.AUDIT_TABLE_NAME_COLUMN, name) + + def create_audit(self, name, id_=DEFAULT_ID, vcpus=None, ram=None, + root_disk=None, ephemeral_disk=None, swap_disk=None): + create_audit_form = self.audits_table.create_audit() + create_audit_form.name.text = name + if id_ is not None: + create_audit_form.audit_id.text = id_ + create_audit_form.vcpus.value = vcpus + create_audit_form.memory_mb.value = ram + create_audit_form.disk_gb.value = root_disk + create_audit_form.eph_gb.value = ephemeral_disk + create_audit_form.swap_mb.value = swap_disk + create_audit_form.submit() + self.wait_till_popups_disappear() + + def delete_audit(self, name): + row = self._get_audit_row(name) + row.mark() + confirm_delete_audits_form = self.audits_table.delete_audit() + confirm_delete_audits_form.submit() + self.wait_till_popups_disappear() + + def is_audit_present(self, name): + return bool(self._get_audit_row(name)) diff --git a/watcher_dashboard/test/integration_tests/pages/admin/optimization/audittemplatespage.py b/watcher_dashboard/test/integration_tests/pages/admin/optimization/audittemplatespage.py new file mode 100644 index 0000000..ee4a708 --- /dev/null +++ b/watcher_dashboard/test/integration_tests/pages/admin/optimization/audittemplatespage.py @@ -0,0 +1,111 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from selenium.webdriver.common import by + +from openstack_dashboard.test.integration_tests.pages import basepage +from openstack_dashboard.test.integration_tests.regions import forms +from openstack_dashboard.test.integration_tests.regions import tables + + +class AuditTemplatesTable(tables.TableRegion): + + name = 'audit_templates' + + CREATE_AUDIT_TEMPLATE_FORM_FIELDS = ("name", "description", "goal") + + @tables.bind_table_action('create') + def create_audit_template(self, create_button): + create_button.click() + return forms.FormRegion( + self.driver, self.conf, + field_mappings=self.CREATE_AUDIT_TEMPLATE_FORM_FIELDS) + + @tables.bind_table_action('delete') + def delete_audit_template(self, delete_button): + delete_button.click() + return forms.BaseFormRegion(self.driver, self.conf, None) + + @tables.bind_row_action('launch_audit', primary=True) + def launch_audit(self, launch_button, row): + launch_button.click() + return forms.BaseFormRegion(self.driver, self.conf) + + +class AudittemplatesPage(basepage.BaseNavigationPage): + + DEFAULT_DESCRIPTION = "Fake description from integration tests" + DEFAULT_GOAL = "BASIC_CONSOLIDATION" + + AUDITS_PAGE_TITLE = "Audits - OpenStack Dashboard" + + AUDIT_TEMPLATE_INFO_SUB_TITLE = "Audit Template Info" + + # Set fields name attribute + CREATE_AUDIT_TEMPLATE_FORM_FIELDS = ( + "name", "description", "goal" + ) + + _audittemplates_info_title_locator = (by.By.CSS_SELECTOR, 'div.detail>h4') + + def __init__(self, driver, conf): + super(AudittemplatesPage, self).__init__(driver, conf) + self._page_title = "Audit Templates" + + @property + def audittemplates_table(self): + return AuditTemplatesTable(self.driver, self.conf) + + @property + def audit_templates__action_create_form(self): + return forms.FormRegion(self.driver, self.conf, None, + self.CREATE_AUDIT_TEMPLATE_FORM_FIELDS) + + def _get_row_with_audit_template_name(self, name): + self._turn_off_implicit_wait() + row = self.audittemplates_table.get_row("name", name) + self._turn_on_implicit_wait() + return row + + def delete_audit_template(self, name): + row = self._get_row_with_audit_template_name(name) + row.mark() + confirm_delete_audit_template_form = ( + self.audittemplates_table.delete_audit_template()) + confirm_delete_audit_template_form.submit() + + def create_audit_template(self, + name, + description=DEFAULT_DESCRIPTION, + goal=DEFAULT_GOAL): + self.audittemplates_table.create_audit_template() + self.audit_templates__action_create_form.name.text = name + self.audit_templates__action_create_form.description.text = description + self.audit_templates__action_create_form.goal.value = goal + self.audit_templates__action_create_form.submit() + + def is_audit_template_present(self, name): + return bool( + self._get_row_with_audit_template_name(name)) + + def launch_audit(self, name): + row = self._get_row_with_audit_template_name(name) + self.audittemplates_table.launch_audit(row) + # Check that the name appears in Audits page + return (self.driver.title == self.AUDITS_PAGE_TITLE) \ + and (name in self.driver.page_source) + + def show_audit_template_info(self, name): + self.driver.find_element_by_link_text(name).click() + info_line = self._get_element(*self._audittemplates_info_title_locator) + return self._is_text_visible( + info_line, self.AUDIT_TEMPLATE_INFO_SUB_TITLE) diff --git a/watcher_dashboard/test/integration_tests/tests/__init__.py b/watcher_dashboard/test/integration_tests/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/integration_tests/tests/audit_template_panel_test.py b/watcher_dashboard/test/integration_tests/tests/audit_template_panel_test.py new file mode 100644 index 0000000..449a3fe --- /dev/null +++ b/watcher_dashboard/test/integration_tests/tests/audit_template_panel_test.py @@ -0,0 +1,80 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest +import uuid + +from openstack_dashboard.test.integration_tests import helpers + + +class AuditTemplateCreatePanelTests(helpers.AdminTestCase): + + def test_create_audit_template(self): + """Test the audit template panel: + + * Loads the audit template panel + * Creates a new audit template with random-generated name + * Checks that this audit template is in list + * Deletes this audit template (in tearDown) + * Checks that the audit template is removed + """ + audit_template_name = "audit_template_%s" % uuid.uuid1() + audit_template_page = \ + self.home_pg.go_to_optimization_audittemplatespage() + audit_template_page.create_audit_template(audit_template_name) + self.assertTrue(audit_template_page.is_audit_template_present( + audit_template_name)) + + +class AuditTemplatePanelTests(helpers.AdminTestCase): + + def setUp(self): + super(AuditTemplatePanelTests, self).setUp() + self.audit_template_name = "audit_template_%s" % uuid.uuid1() + audit_template_page = \ + self.home_pg.go_to_optimization_audittemplatespage() + audit_template_page.create_audit_template(self.audit_template_name) + + def tearDown(self): + audit_template_page = \ + self.home_pg.go_to_optimization_audittemplatespage() + audit_template_page.delete_audit_template(self.audit_template_name) + # Uncomment this line when button will be implemented + self.assertFalse(audit_template_page.is_audit_template_present( + self.audit_template_name)) + super(AuditTemplatePanelTests, self).tearDown() + + def test_show_audit_template_info(self): + """Test the audit template panel information page + + * Loads the audit template panel + * Click on link behind the audit template name + * Checks the info page (only the "Audit Template Info" title for now) + """ + audit_template_page = \ + self.home_pg.go_to_optimization_audittemplatespage() + self.assertTrue( + audit_template_page.show_audit_template_info( + self.audit_template_name)) + + @unittest.skip(reason="https://bugs.launchpad.net/horizon/+bug/1537526") + def test_launch_audit(self): + """Test the audit template panel "Launch Audit" row button + + * Loads the audit template panel + * Click on the button "Launch Audit" + * Checks the audits page for audit template name in page + """ + audit_template_page = \ + self.home_pg.go_to_optimization_audittemplatespage() + self.assertTrue( + audit_template_page.launch_audit(self.audit_template_name)) diff --git a/watcher_dashboard/test/selenium.py b/watcher_dashboard/test/selenium.py new file mode 100644 index 0000000..d45813a --- /dev/null +++ b/watcher_dashboard/test/selenium.py @@ -0,0 +1,55 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import + +import logging + +from horizon.test import helpers as test +from selenium.common import exceptions as selenium_exceptions + + +class BrowserTests(test.SeleniumTestCase): + def test_jasmine(self): + url = "%s%s" % (self.live_server_url, "/jasmine/") + self.selenium.get(url) + wait = self.ui.WebDriverWait(self.selenium, 10) + + def jasmine_done(driver): + text = driver.find_element_by_id("jasmine-testresult").text + return "Tests completed" in text + + wait.until(jasmine_done) + + failed_elem = self.selenium.find_element_by_class_name("failed") + failed = int(failed_elem.text) + if failed: + self.log_failure_messages() + self.assertEqual(failed, 0) + + def log_failure_messages(self): + logger = logging.getLogger('selenium') + logger.error("Errors found during jasmine test:") + fail_elems = self.selenium.find_elements_by_class_name("fail") + for elem in fail_elems: + try: + module = elem.find_element_by_class_name("module-name").text + except selenium_exceptions.NoSuchElementException: + continue + message = elem.find_element_by_class_name("test-message").text + source = elem.find_element_by_tag_name("pre").text + logger.error("Module: %s, message: %s, source: %s" % ( + module, message, source)) diff --git a/watcher_dashboard/test/settings.py b/watcher_dashboard/test/settings.py new file mode 100644 index 0000000..d13a077 --- /dev/null +++ b/watcher_dashboard/test/settings.py @@ -0,0 +1,187 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import importlib +import os +import six + +from horizon.test.settings import * # noqa +from horizon.utils import secret_key +from openstack_dashboard import exceptions + + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_PATH = os.path.abspath(os.path.join(TEST_DIR, "..")) + +MEDIA_ROOT = os.path.abspath(os.path.join(ROOT_PATH, '..', 'media')) +MEDIA_URL = '/media/' +STATIC_ROOT = os.path.abspath(os.path.join(ROOT_PATH, '..', 'static')) +STATIC_URL = '/static/' + +SECRET_KEY = secret_key.generate_or_read_from_file( + os.path.join(TEST_DIR, '.secret_key_store')) +ROOT_URLCONF = 'watcher_dashboard.test.urls' +TEMPLATE_DIRS = ( + os.path.join(TEST_DIR, 'templates'), +) + +TEMPLATE_CONTEXT_PROCESSORS += ( + 'openstack_dashboard.context_processors.openstack', +) + +INSTALLED_APPS = ( + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sessions', + 'django.contrib.staticfiles', + 'django.contrib.messages', + 'django.contrib.humanize', + 'django_nose', + 'openstack_auth', + 'compressor', + 'horizon', + 'openstack_dashboard', + 'openstack_dashboard.dashboards', +) + +AUTHENTICATION_BACKENDS = ('openstack_auth.backend.KeystoneBackend',) + +SITE_BRANDING = 'OpenStack' + +HORIZON_CONFIG = { + "password_validator": { + "regex": '^.{8,18}$', + "help_text": "Password must be between 8 and 18 characters." + }, + 'user_home': None, + 'help_url': "http://docs.openstack.org", + 'exceptions': {'recoverable': exceptions.RECOVERABLE, + 'not_found': exceptions.NOT_FOUND, + 'unauthorized': exceptions.UNAUTHORIZED}, + 'angular_modules': [], + 'js_files': [], +} + +# Load the pluggable dashboard settings +from openstack_dashboard.utils import settings +dashboard_module_names = [ + 'openstack_dashboard.enabled', + 'openstack_dashboard.local.enabled', + 'watcher_dashboard.enabled', +] +dashboard_modules = [] +# All dashboards must be enabled for the namespace to get registered, which is +# needed by the unit tests. +for module_name in dashboard_module_names: + module = importlib.import_module(module_name) + dashboard_modules.append(module) + for submodule in six.itervalues(settings.import_submodules(module)): + if getattr(submodule, 'DISABLED', None): + delattr(submodule, 'DISABLED') +INSTALLED_APPS = list(INSTALLED_APPS) # Make sure it's mutable +settings.update_dashboards(dashboard_modules, HORIZON_CONFIG, INSTALLED_APPS) + +# Set to True to allow users to upload images to glance via Horizon server. +# When enabled, a file form field will appear on the create image form. +# See documentation for deployment considerations. +HORIZON_IMAGES_ALLOW_UPLOAD = True + +AVAILABLE_REGIONS = [ + ('http://localhost:5000/v2.0', 'local'), + ('http://remote:5000/v2.0', 'remote'), +] + +OPENSTACK_API_VERSIONS = { + "identity": 3 +} + +OPENSTACK_KEYSTONE_URL = "http://localhost:5000/v2.0" +OPENSTACK_KEYSTONE_DEFAULT_ROLE = "_member_" + +OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT = False +OPENSTACK_KEYSTONE_DEFAULT_DOMAIN = 'test_domain' + +OPENSTACK_KEYSTONE_BACKEND = { + 'name': 'native', + 'can_edit_user': True, + 'can_edit_group': True, + 'can_edit_project': True, + 'can_edit_domain': True, + 'can_edit_role': True +} + +OPENSTACK_CINDER_FEATURES = { + 'enable_backup': True, +} + +OPENSTACK_NEUTRON_NETWORK = { + 'enable_lb': False, + 'enable_firewall': False, + 'enable_vpn': False +} + +OPENSTACK_HYPERVISOR_FEATURES = { + 'can_set_mount_point': True, + + # NOTE: as of Grizzly this is not yet supported in Nova so enabling this + # setting will not do anything useful + 'can_encrypt_volumes': False +} + +LOGGING['loggers']['openstack_dashboard'] = { + 'handlers': ['test'], + 'propagate': False, +} + +LOGGING['loggers']['selenium'] = { + 'handlers': ['test'], + 'propagate': False, +} + +LOGGING['loggers']['watcher_dashboard'] = { + 'handlers': ['test'], + 'propagate': False, +} + +SECURITY_GROUP_RULES = { + 'all_tcp': { + 'name': 'ALL TCP', + 'ip_protocol': 'tcp', + 'from_port': '1', + 'to_port': '65535', + }, + 'http': { + 'name': 'HTTP', + 'ip_protocol': 'tcp', + 'from_port': '80', + 'to_port': '80', + }, +} + +NOSE_ARGS = ['--nocapture', + '--nologcapture', + '--cover-package=openstack_dashboard', + '--cover-inclusive', + '--all-modules'] + +POLICY_FILES_PATH = os.path.join(ROOT_PATH, "conf") +POLICY_FILES = { + 'identity': 'keystone_policy.json', + 'compute': 'nova_policy.json' +} + +# The openstack_auth.user.Token object isn't JSON-serializable ATM +SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer' diff --git a/watcher_dashboard/test/test_data/__init__.py b/watcher_dashboard/test/test_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/test/test_data/exceptions.py b/watcher_dashboard/test/test_data/exceptions.py new file mode 100644 index 0000000..390772a --- /dev/null +++ b/watcher_dashboard/test/test_data/exceptions.py @@ -0,0 +1,26 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from openstack_dashboard.test.test_data import exceptions +from watcherclient.common.apiclient import exceptions as wexceptions + + +def data(TEST): + TEST.exceptions = exceptions.data + + watcher_exception = wexceptions.ClientException + TEST.exceptions.watcher = exceptions.create_stubbed_exception( + watcher_exception) diff --git a/watcher_dashboard/test/test_data/utils.py b/watcher_dashboard/test/test_data/utils.py new file mode 100644 index 0000000..aab401c --- /dev/null +++ b/watcher_dashboard/test/test_data/utils.py @@ -0,0 +1,58 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from openstack_dashboard.test.test_data import keystone_data +from openstack_dashboard.test.test_data import utils + + +def load_test_data(load_onto=None): + from watcher_dashboard.test.test_data import exceptions + from watcher_dashboard.test.test_data import watcher_data + + # The order of these loaders matters, some depend on others. + loaders = (exceptions.data, + keystone_data.data, + watcher_data.data) + if load_onto: + for data_func in loaders: + data_func(load_onto) + return load_onto + else: + return utils.TestData(*loaders) + + +class TestDataContainer(utils.TestDataContainer): + def filter(self, filtered=None, **kwargs): + """Returns objects in this container """ + """whose attributes match the given keyword arguments. + """ + if filtered is None: + filtered = self._objects + try: + key, value = kwargs.popitem() + except KeyError: + # We're out of filters, return + return filtered + + def get_match(obj): + return key in obj and obj.get(key) == value + + return self.filter(filtered=filter(get_match, filtered), **kwargs) + + def delete(self): + """Delete the first object from this container and return a list""" + self._objects.remove(self._objects[0]) + return self._objects diff --git a/watcher_dashboard/test/test_data/watcher_data.py b/watcher_dashboard/test/test_data/watcher_data.py new file mode 100644 index 0000000..023b4d5 --- /dev/null +++ b/watcher_dashboard/test/test_data/watcher_data.py @@ -0,0 +1,133 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import copy + +from watcher_dashboard.api import watcher +from watcher_dashboard.test.test_data import utils + + +def data(TEST): + + TEST.service_catalog.append( + {"type": "infra-optim", + "name": "watcher", + "endpoints_links": [], + "endpoints": [ + {"region": "RegionOne", + "adminURL": "http://admin.watcher.example.com:9322", + "internalURL": "http://int.watcher.example.com:9322", + "publicURL": "http://public.watcher.example.com:9322"}, + {"region": "RegionTwo", + "adminURL": "http://admin.watcher2.example.com:9322", + "internalURL": "http://int.watcher2.example.com:9322", + "publicURL": "http://public.watcher2.example.com:9322"}]}, + ) + + TEST.audit_templates = utils.TestDataContainer() + TEST.api_audit_templates = utils.TestDataContainer() + audit_template_dict = { + 'uuid': '11111111-1111-1111-1111-111111111111', + 'name': 'Audit Template 1', + 'description': 'Audit Template 1 description', + 'host_aggregate': None, + 'extra': {'automatic': False}, + 'goal': 'MINIMIZE_LICENSING_COST' + } + audit_template_dict2 = { + 'uuid': '11111111-2222-2222-2222-111111111111', + 'name': 'Audit Template 2', + 'description': 'Audit Template 2 description', + 'host_aggregate': None, + 'extra': {'automatic': False}, + 'goal': 'MINIMIZE_LICENSING_COST' + } + audit_template_dict3 = { + 'uuid': '11111111-3333-3333-3333-111111111111', + 'name': 'Audit Template 1', + 'description': 'Audit Template 3 description', + 'host_aggregate': None, + 'extra': {'automatic': False}, + 'goal': 'MINIMIZE_LICENSING_COST' + } + TEST.api_audit_templates.add(audit_template_dict) + TEST.api_audit_templates.add(audit_template_dict2) + TEST.api_audit_templates.add(audit_template_dict3) + _audit_template_dict = copy.deepcopy(audit_template_dict) + _audit_template_dict2 = copy.deepcopy(audit_template_dict2) + _audit_template_dict3 = copy.deepcopy(audit_template_dict3) + + TEST.goals = utils.TestDataContainer() + TEST.api_goals = utils.TestDataContainer() + + TEST.audits = utils.TestDataContainer() + TEST.api_audits = utils.TestDataContainer() + audit_dict = { + 'id': '22222222-2222-2222-2222-222222222222', + 'deadline': None, + 'type': 'ONE_SHOT', + 'audit_template_uuid': '11111111-1111-1111-1111-111111111111' + } + TEST.api_audits.add(audit_dict) + _audit_dict = copy.deepcopy(audit_dict) + + TEST.action_plans = utils.TestDataContainer() + TEST.api_action_plans = utils.TestDataContainer() + action_plan_dict = { + 'id': '33333333-3333-3333-3333-333333333333', + 'state': 'RECOMMENDED', + 'first_action_uuid': '44444444-4444-4444-4444-111111111111', + 'audit_uuid': '22222222-2222-2222-2222-222222222222' + } + TEST.api_action_plans.add(action_plan_dict) + _action_plan_dict = copy.deepcopy(action_plan_dict) + + TEST.actions = utils.TestDataContainer() + TEST.api_actions = utils.TestDataContainer() + action_dict1 = { + 'id': '44444444-4444-4444-4444-111111111111', + 'state': 'PENDING', + 'next_uuid': '44444444-4444-4444-4444-222222222222', + 'action_plan_uuid': '33333333-3333-3333-3333-333333333333' + } + TEST.api_actions.add(action_dict1) + + action_dict2 = { + 'id': '44444444-4444-4444-4444-222222222222', + 'state': 'PENDING', + 'next_uuid': None, + 'action_plan_uuid': '33333333-3333-3333-3333-333333333333' + } + TEST.api_actions.add(action_dict2) + + action2 = watcher.Action(action_dict2) + action1 = watcher.Action(action_dict1) + TEST.actions.add(action1) + TEST.actions.add(action2) + + _action_plan_dict['actions'] = [action1, action2] + action_plan = watcher.ActionPlan(_action_plan_dict) + + _audit_dict['action_plans'] = [action_plan] + audit = watcher.Audit(_audit_dict) + + # _audit_template_dict['audits'] = [audit] + audit_template1 = watcher.AuditTemplate(_audit_template_dict) + audit_template2 = watcher.AuditTemplate(_audit_template_dict2) + audit_template3 = watcher.AuditTemplate(_audit_template_dict3) + + TEST.audit_templates.add(audit_template1) + TEST.audit_templates.add(audit_template2) + TEST.audit_templates.add(audit_template3) + TEST.audits.add(audit) + TEST.action_plans.add(watcher.ActionPlan(action_plan)) diff --git a/watcher_dashboard/test/urls.py b/watcher_dashboard/test/urls.py new file mode 100644 index 0000000..9bef20f --- /dev/null +++ b/watcher_dashboard/test/urls.py @@ -0,0 +1,20 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.conf import urls +import openstack_dashboard.urls + +urlpatterns = urls.patterns( + '', + urls.url(r'', urls.include(openstack_dashboard.urls)) +) diff --git a/watcher_dashboard/utils/__init__.py b/watcher_dashboard/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/watcher_dashboard/utils/cached_property.py b/watcher_dashboard/utils/cached_property.py new file mode 100644 index 0000000..7e37044 --- /dev/null +++ b/watcher_dashboard/utils/cached_property.py @@ -0,0 +1,63 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of Django nor the names of its contributors may be +# used to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +# We would be using django.utils.functional.cached_property, except it +# breaks when used with mox in our tests, because of +# https://code.djangoproject.com/ticket/19872 +# +# So we have a copy of it here, with the bug fixed. +# FIXME: Use django's version when the bug is fixed there. +class cached_property(object): + """Cached property decorator. + + Decorator that creates converts a method with a single self argument + into a property cached on the instance. + """ + + def __init__(self, func): + self.func = func + + def __get__(self, instance, type): + if instance is None: + return self + res = instance.__dict__[self.func.__name__] = self.func(instance) + return res diff --git a/watcher_dashboard/utils/errors.py b/watcher_dashboard/utils/errors.py new file mode 100644 index 0000000..a505f4e --- /dev/null +++ b/watcher_dashboard/utils/errors.py @@ -0,0 +1,71 @@ +# -*- coding: utf8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import functools +import inspect + +import horizon.exceptions + + +def handle_errors(error_message, error_default=None, request_arg=None): + """A decorator for adding default error handling to API calls. + + It wraps the original method in a try-except block, with horizon's + error handling added. + + Note: it should only be used on functions or methods that take request as + their argument (it has to be named "request", or ``request_arg`` has to be + provided, indicating which argument is the request). + + The decorated method accepts a number of additional parameters: + + :param _error_handle: whether to handle the errors in this call + :param _error_message: override the error message + :param _error_default: override the default value returned on error + :param _error_redirect: specify a redirect url for errors + :param _error_ignore: ignore known errors + """ + def decorator(func): + # XXX This is an ugly hack for finding the 'request' argument. + if request_arg is None: + for _request_arg, name in enumerate(inspect.getargspec(func).args): + if name == 'request': + break + else: + raise RuntimeError( + "The handle_errors decorator requires 'request' as " + "an argument of the function or method being decorated") + else: + _request_arg = request_arg + + @functools.wraps(func) + def wrapper(*args, **kwargs): + _error_handle = kwargs.pop('_error_handle', True) + _error_message = kwargs.pop('_error_message', error_message) + _error_default = kwargs.pop('_error_default', error_default) + _error_redirect = kwargs.pop('_error_redirect', None) + _error_ignore = kwargs.pop('_error_ignore', False) + if not _error_handle: + return func(*args, **kwargs) + try: + return func(*args, **kwargs) + except Exception: + request = args[_request_arg] + horizon.exceptions.handle(request, _error_message, + ignore=_error_ignore, + redirect=_error_redirect) + return _error_default + wrapper.wrapped = func + return wrapper + return decorator diff --git a/watcher_dashboard/utils/metering.py b/watcher_dashboard/utils/metering.py new file mode 100644 index 0000000..49476fa --- /dev/null +++ b/watcher_dashboard/utils/metering.py @@ -0,0 +1,297 @@ +# -*- coding: utf8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import unicode_literals + +import copy + +from django.utils.http import urlencode +from django.utils.translation import ugettext_lazy as _ +from horizon import exceptions +from openstack_dashboard.api import ceilometer +from openstack_dashboard.utils import metering + +SETTINGS = { + 'settings': { + 'renderer': 'StaticAxes', + 'xMin': None, + 'xMax': None, + 'higlight_last_point': True, + 'auto_size': False, + 'auto_resize': False, + 'axes_x': False, + 'axes_y': True, + 'axes_y_label': False, + 'bar_chart_settings': { + 'orientation': 'vertical', + 'used_label_placement': 'left', + 'width': 30, + 'color_scale_domain': [0, 80, 80, 100], + 'color_scale_range': [ + '#0000FF', + '#0000FF', + '#FF0000', + '#FF0000' + ], + 'average_color_scale_domain': [0, 100], + 'average_color_scale_range': ['#0000FF', '#0000FF'] + } + }, + 'stats': { + 'average': None, + 'used': None, + 'tooltip_average': None, + } +} + +LABELS = { + 'hardware.cpu.load.1min': _("CPU load 1 min average"), + 'hardware.system_stats.cpu.util': _("CPU utilization"), + 'hardware.system_stats.io.outgoing.blocks': _("IO raw sent"), + 'hardware.system_stats.io.incoming.blocks': _("IO raw received"), + 'hardware.network.ip.outgoing.datagrams': _("IP out requests"), + 'hardware.network.ip.incoming.datagrams': _("IP in requests"), + 'hardware.memory.swap.util': _("Swap utilization"), + 'hardware.ipmi.fan': _("Fan Speed"), + 'hardware.ipmi.voltage': _("Votage"), + 'hardware.ipmi.temperature': _("Temperature"), + 'hardware.ipmi.current': _("Current") +} + + +# TODO(lsmola) this should probably live in Horizon common +def query_data(request, + date_from, + date_to, + group_by, + meter, + period=None, + query=None, + additional_query=None): + + if not period: + period = metering.calc_period(date_from, date_to, 50) + if additional_query is None: + additional_query = [] + if date_from: + additional_query += [{'field': 'timestamp', + 'op': 'ge', + 'value': date_from}] + if date_to: + additional_query += [{'field': 'timestamp', + 'op': 'le', + 'value': date_to}] + + # TODO(lsmola) replace this by logic implemented in I1 in bugs + # 1226479 and 1226482, this is just a quick fix for RC1 + ceilometer_usage = ceilometer.CeilometerUsage(request) + try: + if group_by: + resources = ceilometer_usage.resource_aggregates_with_statistics( + query, [meter], period=period, stats_attr=None, + additional_query=additional_query) + else: + resources = ceilometer_usage.resources_with_statistics( + query, [meter], period=period, stats_attr=None, + additional_query=additional_query) + except Exception: + resources = [] + exceptions.handle(request, + _('Unable to retrieve statistics.')) + return resources + + +def url_part(meter_name, barchart): + d = {'meter': meter_name} + if barchart: + d['barchart'] = True + return urlencode(d) + + +def get_meter_name(meter): + return meter.replace('.', '_') + + +def get_meter_list_and_unit(request, meter): + try: + meter_list = [m for m in ceilometer.meter_list(request) + if m.name == meter] + unit = meter_list[0].unit + except Exception: + meter_list = [] + unit = '' + + return meter_list, unit + + +def get_meters(meters): + return [(m, get_meter_name(m)) for m in meters] + + +def get_barchart_stats(series, unit): + values = [point['y'] for point in series[0]['data']] + average = sum(values) / float(len(values)) # Python 2/3 compatibility + used = values[-1] + first_date = series[0]['data'][0]['x'] + last_date = series[0]['data'][-1]['x'] + tooltip_average = _('Average %(average)s %(unit)s
    From: ' + '%(first_date)s, to: %(last_date)s') % ( + dict(average=average, unit=unit, + first_date=first_date, + last_date=last_date)) + return average, used, tooltip_average + + +def create_json_output(series, barchart, unit, date_from, date_to): + start_datetime = end_datetime = '' + if date_from: + start_datetime = date_from.strftime("%Y-%m-%dT%H:%M:%S") + if date_to: + end_datetime = date_to.strftime("%Y-%m-%dT%H:%M:%S") + + settings = copy.deepcopy(SETTINGS) + settings['settings']['xMin'] = start_datetime + settings['settings']['xMax'] = end_datetime + + if series and barchart: + average, used, tooltip_average = get_barchart_stats(series, unit) + settings['settings']['yMin'] = 0 + settings['settings']['yMax'] = 100 + settings['stats']['average'] = average + settings['stats']['used'] = used + settings['stats']['tooltip_average'] = tooltip_average + else: + del settings['settings']['bar_chart_settings'] + del settings['stats'] + + json_output = {'series': series} + json_output.update(settings) + return json_output + + +def get_nodes_stats(request, node_uuid, instance_uuid, meter, + date_options=None, date_from=None, date_to=None, + stats_attr=None, barchart=None, group_by=None): + series = [] + meter_list, unit = get_meter_list_and_unit(request, meter) + + if instance_uuid: + if 'ipmi' in meter: + # For IPMI metrics, a resource ID is made of node UUID concatenated + # with the metric description. E.g: + # 1dcf1896-f581-4027-9efa-973eef3380d2-fan_2a_tach_(0x42) + resource_ids = [m.resource_id for m in meter_list + if m.resource_id.startswith(node_uuid)] + queries = [ + [{'field': 'resource_id', + 'op': 'eq', + 'value': resource_id}] + for resource_id in resource_ids + ] + else: + # For SNMP metrics, a resource ID matches exactly the UUID of the + # associated instance + if group_by == "image_id": + query = {} + image_query = [{"field": "metadata.%s" % group_by, + "op": "eq", + "value": instance_uuid}] + query[instance_uuid] = image_query + else: + query = [{'field': 'resource_id', + 'op': 'eq', + 'value': instance_uuid}] + queries = [query] + else: + # query will be aggregated across all resources + group_by = "all" + query = {'all': []} + queries = [query] + + # Disk and Network I/O: data from 2 meters in one chart + if meter == 'disk-io': + meters = get_meters([ + 'hardware.system_stats.io.outgoing.blocks', + 'hardware.system_stats.io.incoming.blocks' + ]) + elif meter == 'network-io': + meters = get_meters([ + 'hardware.network.ip.outgoing.datagrams', + 'hardware.network.ip.incoming.datagrams' + ]) + else: + meters = get_meters([meter]) + + date_from, date_to = metering.calc_date_args( + date_from, + date_to, + date_options) + + for meter_id, meter_name in meters: + label = str(LABELS.get(meter_id, meter_name)) + + for query in queries: + resources = query_data( + request=request, + date_from=date_from, + date_to=date_to, + group_by=group_by, + meter=meter_id, + query=query) + s = metering.series_for_meter(request, resources, group_by, + meter_id, meter_name, stats_attr, + unit, label) + series += s + + series = metering.normalize_series_by_unit(series) + + json_output = create_json_output( + series, + barchart, + unit, + date_from, + date_to) + + return json_output + + +def get_top_5(request, meter): + ceilometer_usage = ceilometer.CeilometerUsage(request) + meter_list, unit = get_meter_list_and_unit(request, meter) + top_5 = {'unit': unit, 'label': LABELS.get(meter, meter)} + data = [] + + for m in meter_list: + query = [{'field': 'resource_id', 'op': 'eq', 'value': m.resource_id}] + resource = ceilometer_usage.resources_with_statistics(query, [m.name], + period=600)[0] + node_uuid = resource.metadata['node'] + old_value, value = resource.get_meter(get_meter_name(m.name))[-2:] + old_value, value = old_value.max, value.max + + if value > old_value: + direction = 'up' + elif value < old_value: + direction = 'down' + else: + direction = None + + data.append({ + 'node_uuid': node_uuid, + 'value': value, + 'direction': direction + }) + + top_5['data'] = sorted(data, key=lambda d: d['value'], reverse=True)[:5] + return top_5 diff --git a/watcher_dashboard/utils/tests.py b/watcher_dashboard/utils/tests.py new file mode 100644 index 0000000..eca41f3 --- /dev/null +++ b/watcher_dashboard/utils/tests.py @@ -0,0 +1,208 @@ +# -*- coding: utf8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import collections +import datetime + +import mock + +from watcher_dashboard.test import helpers + +from watcher_dashboard.utils import metering +from watcher_dashboard.utils import utils + + +class UtilsTests(helpers.TestCase): + def test_de_camel_case(self): + ret = utils.de_camel_case('CamelCaseString') + self.assertEqual(ret, 'Camel Case String') + ret = utils.de_camel_case('SecureSSLConnection') + self.assertEqual(ret, 'Secure SSL Connection') + ret = utils.de_camel_case('xxXXxx') + self.assertEqual(ret, 'xx X Xxx') + ret = utils.de_camel_case('XXX') + self.assertEqual(ret, 'XXX') + ret = utils.de_camel_case('NON Camel Case') + self.assertEqual(ret, 'NON Camel Case') + + def test_list_to_dict(self): + Item = collections.namedtuple('Item', 'id') + ret = utils.list_to_dict([Item('foo'), Item('bar'), Item('bar')]) + self.assertEqual(ret, {'foo': Item('foo'), 'bar': Item('bar')}) + + def test_length(self): + ret = utils.length(iter([])) + self.assertEqual(ret, 0) + ret = utils.length(iter([1, 2, 3])) + self.assertEqual(ret, 3) + + def test_check_image_type(self): + Image = collections.namedtuple('Image', 'properties') + ret = utils.check_image_type(Image({'type': 'Picasso'}), 'Picasso') + self.assertTrue(ret) + ret = utils.check_image_type(Image({'type': 'Picasso'}), 'Van Gogh') + self.assertFalse(ret) + ret = utils.check_image_type(Image({}), 'Van Gogh') + self.assertTrue(ret) + + def test_filter_items(self): + Item = collections.namedtuple('Item', 'index') + items = [Item(i) for i in range(7)] + ret = list(utils.filter_items(items, index=1)) + self.assertEqual(ret, [Item(1)]) + ret = list(utils.filter_items(items, index__in=(1, 2, 3))) + self.assertEqual(ret, [Item(1), Item(2), Item(3)]) + ret = list(utils.filter_items(items, index__not_in=(1, 2, 3))) + self.assertEqual(ret, [Item(0), Item(4), Item(5), Item(6)]) + + def test_safe_int_cast(self): + ret = utils.safe_int_cast(1) + self.assertEqual(ret, 1) + ret = utils.safe_int_cast('1') + self.assertEqual(ret, 1) + ret = utils.safe_int_cast('') + self.assertEqual(ret, 0) + ret = utils.safe_int_cast(None) + self.assertEqual(ret, 0) + ret = utils.safe_int_cast(object()) + self.assertEqual(ret, 0) + + +class MeteringTests(helpers.TestCase): + def test_query_data(self): + Meter = collections.namedtuple('Meter', 'name unit') + request = 'request' + from_date = datetime.datetime(2015, 1, 1, 13, 45) + to_date = datetime.datetime(2015, 1, 2, 13, 45) + with mock.patch( + 'openstack_dashboard.api.ceilometer.meter_list', + return_value=[Meter('foo.bar', u'µD')], + ), mock.patch( + 'openstack_dashboard.api.ceilometer.CeilometerUsage', + return_value=mock.MagicMock(**{ + 'resource_aggregates_with_statistics.return_value': 'plonk', + }), + ): + ret = metering.query_data(request, from_date, to_date, + 'all', 'foo.bar') + self.assertEqual(ret, 'plonk') + + def test_url_part(self): + ret = metering.url_part('foo_bar_baz', True) + self.assertTrue('meter=foo_bar_baz' in ret) + self.assertTrue('barchart=True' in ret) + ret = metering.url_part('foo_bar_baz', False) + self.assertTrue('meter=foo_bar_baz' in ret) + self.assertFalse('barchart=True' in ret) + + def test_get_meter_name(self): + ret = metering.get_meter_name('foo.bar.baz') + self.assertEqual(ret, 'foo_bar_baz') + + def test_get_meters(self): + ret = metering.get_meters(['foo.bar', 'foo.baz']) + self.assertEqual(ret, [('foo.bar', 'foo_bar'), ('foo.baz', 'foo_baz')]) + + def test_get_barchart_stats(self): + series = [ + {'data': [{'x': 1, 'y': 1}, {'x': 4, 'y': 4}]}, + {'data': [{'x': 2, 'y': 2}, {'x': 5, 'y': 5}]}, + {'data': [{'x': 3, 'y': 3}, {'x': 6, 'y': 6}]}, + ] + # Arrogance is measured in IT in micro-Dijkstras, µD. + average, used, tooltip_average = metering.get_barchart_stats(series, + u'µD') + self.assertEqual(average, 2.5) + self.assertEqual(used, 4) + self.assertEqual(tooltip_average, u'Average 2.5 µD
    From: 1, to: 4') + + def test_create_json_output(self): + ret = metering.create_json_output([], False, u'µD', None, None) + self.assertEqual(ret, { + 'series': [], + 'settings': { + 'higlight_last_point': True, + 'axes_x': False, + 'axes_y': True, + 'xMin': '', + 'renderer': 'StaticAxes', + 'xMax': '', + 'axes_y_label': False, + 'auto_size': False, + 'auto_resize': False, + }, + }) + + series = [ + {'data': [{'x': 1, 'y': 1}, {'x': 4, 'y': 4}]}, + {'data': [{'x': 2, 'y': 2}, {'x': 5, 'y': 5}]}, + {'data': [{'x': 3, 'y': 3}, {'x': 6, 'y': 6}]}, + ] + ret = metering.create_json_output(series, True, u'µD', None, None) + self.assertEqual(ret, { + 'series': series, + 'stats': { + 'average': 2.5, + 'used': 4, + 'tooltip_average': u'Average 2.5 µD
    From: 1, to: 4', + }, + 'settings': { + 'yMin': 0, + 'yMax': 100, + 'higlight_last_point': True, + 'axes_x': False, + 'axes_y': True, + 'bar_chart_settings': { + 'color_scale_domain': [0, 80, 80, 100], + 'orientation': 'vertical', + 'color_scale_range': [ + '#0000FF', + '#0000FF', + '#FF0000', + '#FF0000', + ], + 'width': 30, + 'average_color_scale_domain': [0, 100], + 'used_label_placement': 'left', + 'average_color_scale_range': ['#0000FF', '#0000FF'], + }, + 'xMin': '', + 'renderer': 'StaticAxes', + 'xMax': '', + 'axes_y_label': False, + 'auto_size': False, + 'auto_resize': False, + }, + }) + + def test_get_nodes_stats(self): + request = 'request' + with mock.patch( + 'watcher_dashboard.utils.metering.create_json_output', + return_value='', + ) as create_json_output, mock.patch( + 'watcher_dashboard.utils.metering.query_data', + return_value=[], + ), mock.patch( + 'openstack_dashboard.utils.metering.series_for_meter', + return_value=[], + ), mock.patch( + 'openstack_dashboard.utils.metering.calc_date_args', + return_value=('from date', 'to date'), + ): + ret = metering.get_nodes_stats(request, 'abc', 'def', 'foo.bar') + self.assertEqual(ret, '') + self.assertEqual(create_json_output.call_args_list, [ + mock.call([], None, '', 'from date', 'to date') + ]) diff --git a/watcher_dashboard/utils/utils.py b/watcher_dashboard/utils/utils.py new file mode 100644 index 0000000..a75eb24 --- /dev/null +++ b/watcher_dashboard/utils/utils.py @@ -0,0 +1,94 @@ +# -*- coding: utf8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import re + +CAMEL_RE = re.compile(r'([A-Z][a-z]+|[A-Z]+(?=[A-Z\s]|$))') + + +def de_camel_case(text): + """Convert CamelCase names to human-readable format.""" + return ' '.join(w.strip() for w in CAMEL_RE.split(text) if w.strip()) + + +def list_to_dict(object_list, key_attribute='id'): + """Converts an object list to a dict + + :param object_list: list of objects to be put into a dict + :type object_list: list + + :param key_attribute: object attribute used as index by dict + :type key_attribute: str + + :return: dict containing the objects in the list + :rtype: dict + """ + return dict((getattr(o, key_attribute), o) for o in object_list) + + +def length(iterator): + """A length function for iterators + + Returns the number of items in the specified iterator. Note that this + function consumes the iterator in the process. + """ + return sum(1 for _item in iterator) + + +def check_image_type(image, type): + """Check if image 'type' property matches passed-in type. + + If image has no 'type' property' return True, as we cannot + be sure what type of image it is. + """ + + return (image.properties.get('type', type) == type) + + +def filter_items(items, **kwargs): + """Filters the list of items and returns the filtered list. + + Example usage: + >>> class Item(object): + ... def __init__(self, index): + ... self.index = index + ... def __repr__(self): + ... return '' % self.index + >>> items = [Item(i) for i in range(7)] + >>> list(filter_items(items, index=1)) + [] + >>> list(filter_items(items, index__in=(1, 2, 3))) + [, , ] + >>> list(filter_items(items, index__not_in=(1, 2, 3))) + [, , , ] + """ + for item in items: + for name, value in kwargs.items(): + if name.endswith('__in'): + if getattr(item, name[:-len('__in')]) not in value: + break + elif name.endswith('__not_in'): + if getattr(item, name[:-len('__not_in')]) in value: + break + else: + if getattr(item, name) != value: + break + else: + yield item + + +def safe_int_cast(value): + try: + return int(value) + except (TypeError, ValueError): + return 0