From 1721ba9c4ab4c69ff4bc49f345cf00cc9718d8ea Mon Sep 17 00:00:00 2001 From: Gabriel Hurley Date: Thu, 26 Apr 2012 19:22:51 -0700 Subject: [PATCH] Adds dash/panel app templates, mgmt commands, template loader. Implements blueprint scaffolding. Using custom management commands you can now create the majority of the boilerplate code for a new dashboard or panel from a set of basic templates with a single command. See the docs for more info. Additionally, in support of the new commands (and inherent codified directory structure) there's a new template loader included which can load templates from "templates" directories in any registered panel. Change-Id: I1df5eb152cb18694dc89d562799c8d3e8950ca6f --- docs/source/ref/run_tests.rst | 42 +++++++++ docs/source/topics/tutorial.rst | 32 ++++++- horizon/base.py | 20 ++++- horizon/conf/__init__.py | 0 horizon/conf/dash_template/__init__.py | 0 horizon/conf/dash_template/dashboard.py | 13 +++ horizon/conf/dash_template/models.py | 3 + .../static/dash_name/css/dash_name.css | 1 + .../static/dash_name/js/dash_name.js | 1 + .../templates/dash_name/base.html | 11 +++ horizon/conf/panel_template/__init__.py | 0 horizon/conf/panel_template/models.py | 3 + horizon/conf/panel_template/panel.py | 13 +++ .../templates/panel_name/index.html | 12 +++ horizon/conf/panel_template/tests.py | 7 ++ horizon/conf/panel_template/urls.py | 7 ++ horizon/conf/panel_template/views.py | 10 +++ horizon/loaders.py | 46 ++++++++++ horizon/management/__init__.py | 0 horizon/management/commands/__init__.py | 0 horizon/management/commands/startdash.py | 49 ++++++++++ horizon/management/commands/startpanel.py | 89 +++++++++++++++++++ horizon/templatetags/horizon.py | 7 +- horizon/tests/testsettings.py | 1 + openstack_dashboard/settings.py | 3 +- run_tests.sh | 24 ++++- tools/test-requires | 1 + 27 files changed, 385 insertions(+), 10 deletions(-) create mode 100644 horizon/conf/__init__.py create mode 100644 horizon/conf/dash_template/__init__.py create mode 100644 horizon/conf/dash_template/dashboard.py create mode 100644 horizon/conf/dash_template/models.py create mode 100644 horizon/conf/dash_template/static/dash_name/css/dash_name.css create mode 100644 horizon/conf/dash_template/static/dash_name/js/dash_name.js create mode 100644 horizon/conf/dash_template/templates/dash_name/base.html create mode 100644 horizon/conf/panel_template/__init__.py create mode 100644 horizon/conf/panel_template/models.py create mode 100644 horizon/conf/panel_template/panel.py create mode 100644 horizon/conf/panel_template/templates/panel_name/index.html create mode 100644 horizon/conf/panel_template/tests.py create mode 100644 horizon/conf/panel_template/urls.py create mode 100644 horizon/conf/panel_template/views.py create mode 100644 horizon/loaders.py create mode 100644 horizon/management/__init__.py create mode 100644 horizon/management/commands/__init__.py create mode 100644 horizon/management/commands/startdash.py create mode 100644 horizon/management/commands/startpanel.py diff --git a/docs/source/ref/run_tests.rst b/docs/source/ref/run_tests.rst index 1868554f5..579d7d48b 100644 --- a/docs/source/ref/run_tests.rst +++ b/docs/source/ref/run_tests.rst @@ -43,6 +43,48 @@ tests by using the ``--skip-selenium`` flag:: This isn't recommended, but can be a timesaver when you only need to run the code tests and not the frontend tests during development. +Using Dashboard and Panel Templates +=================================== + +Horizon has a set of convenient management commands for creating new +dashboards and panels based on basic templates. + +Dashboards +---------- + +To create a new dashboard, run the following: + + ./run_tests.sh -m startdash + +This will create a directory with the given dashboard name, a ``dashboard.py`` +module with the basic dashboard code filled in, and various other common +"boilerplate" code. + +Available options: + +* --target: the directory in which the dashboard files should be created. + Default: A new directory within the current directory. + +Panels +------ + +To create a new panel, run the following: + + ./run_tests -m startpanel --dashboard= + +This will create a directory with the given panel name, and ``panel.py`` +module with the basic panel code filled in, and various other common +"boilerplate" code. + +Available options: + +* -d, --dashboard: The dotted python path to your dashboard app (the module + which containers the ``dashboard.py`` file.). +* --target: the directory in which the panel files should be created. + If the value is ``auto`` the panel will be created as a new directory inside + the dashboard module's directory structure. Default: A new directory within + the current directory. + Give me metrics! ================ diff --git a/docs/source/topics/tutorial.rst b/docs/source/topics/tutorial.rst index e8d6b3b93..1a6f48da8 100644 --- a/docs/source/topics/tutorial.rst +++ b/docs/source/topics/tutorial.rst @@ -31,6 +31,17 @@ Creating a dashboard incorporate it into an existing dashboard. See the section :ref:`overrides ` later in this document. +The quick version +----------------- + +Horizon provides a custom management command to create a typical base +dashboard structure for you. The following command generates most of the +boilerplate code explained below:: + + ./run_tests.sh -m startdash visualizations + +It's still recommended that you read the rest of this section to understand +what that command creates and why. Structure --------- @@ -116,13 +127,32 @@ but it could also go elsewhere, such as in an override file (see below). Creating a panel ================ -Now that we have our dashboard written, we can also create our panel. +Now that we have our dashboard written, we can also create our panel. We'll +call it "flocking". .. note:: You don't need to write a custom dashboard to add a panel. The structure here is for the sake of completeness in the tutorial. +The quick version +----------------- + +Horizon provides a custom management command to create a typical base +panel structure for you. The following command generates most of the +boilerplate code explained below:: + + ./run_tests.sh -m startpanel flocking --dashboard=visualizations --target=auto + +The ``dashboard`` argument is required, and tells the command which dashboard +this panel will be registered with. The ``target`` argument is optional, and +respects ``auto`` as a special value which means that the files for the panel +should be created inside the dashboard module as opposed to the current +directory (the default). + +It's still recommended that you read the rest of this section to understand +what that command creates and why. + Structure --------- diff --git a/horizon/base.py b/horizon/base.py index da6a56e63..ed4d779af 100644 --- a/horizon/base.py +++ b/horizon/base.py @@ -26,6 +26,7 @@ import collections import copy import inspect import logging +import os from django.conf import settings from django.conf.urls.defaults import patterns, url, include @@ -37,6 +38,7 @@ from django.utils.importlib import import_module from django.utils.module_loading import module_has_submodule from django.utils.translation import ugettext as _ +from horizon import loaders from horizon.decorators import (require_auth, require_roles, require_services, _current_component) @@ -541,12 +543,26 @@ class Dashboard(Registry, HorizonComponent): @classmethod def register(cls, panel): """ Registers a :class:`~horizon.Panel` with this dashboard. """ - return Horizon.register_panel(cls, panel) + panel_class = Horizon.register_panel(cls, panel) + # Support template loading from panel template directories. + panel_mod = import_module(panel.__module__) + panel_dir = os.path.dirname(panel_mod.__file__) + template_dir = os.path.join(panel_dir, "templates") + if os.path.exists(template_dir): + key = os.path.join(cls.slug, panel.slug) + loaders.panel_template_dirs[key] = template_dir + return panel_class @classmethod def unregister(cls, panel): """ Unregisters a :class:`~horizon.Panel` from this dashboard. """ - return Horizon.unregister_panel(cls, panel) + success = Horizon.unregister_panel(cls, panel) + if success: + # Remove the panel's template directory. + key = os.path.join(cls.slug, panel.slug) + if key in loaders.panel_template_dirs: + del loaders.panel_template_dirs[key] + return success class Workflow(object): diff --git a/horizon/conf/__init__.py b/horizon/conf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horizon/conf/dash_template/__init__.py b/horizon/conf/dash_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horizon/conf/dash_template/dashboard.py b/horizon/conf/dash_template/dashboard.py new file mode 100644 index 000000000..9e435bef3 --- /dev/null +++ b/horizon/conf/dash_template/dashboard.py @@ -0,0 +1,13 @@ +from django.utils.translation import ugettext_lazy as _ + +import horizon + + +class {{ dash_name|title }}(horizon.Dashboard): + name = _("{{ dash_name|title }}") + slug = "{{ dash_name|slugify }}" + panels = () # Add your panels here. + default_panel = '' # Specify the slug of the dashboard's default panel. + + +horizon.register({{ dash_name|title }}) diff --git a/horizon/conf/dash_template/models.py b/horizon/conf/dash_template/models.py new file mode 100644 index 000000000..1b3d5f9ef --- /dev/null +++ b/horizon/conf/dash_template/models.py @@ -0,0 +1,3 @@ +""" +Stub file to work around django bug: https://code.djangoproject.com/ticket/7198 +""" diff --git a/horizon/conf/dash_template/static/dash_name/css/dash_name.css b/horizon/conf/dash_template/static/dash_name/css/dash_name.css new file mode 100644 index 000000000..ed03b4f6d --- /dev/null +++ b/horizon/conf/dash_template/static/dash_name/css/dash_name.css @@ -0,0 +1 @@ +/* Additional CSS for {{ dash_name }}. */ diff --git a/horizon/conf/dash_template/static/dash_name/js/dash_name.js b/horizon/conf/dash_template/static/dash_name/js/dash_name.js new file mode 100644 index 000000000..a8088523d --- /dev/null +++ b/horizon/conf/dash_template/static/dash_name/js/dash_name.js @@ -0,0 +1 @@ +/* Additional JavaScript for {{ dash_name }}. */ diff --git a/horizon/conf/dash_template/templates/dash_name/base.html b/horizon/conf/dash_template/templates/dash_name/base.html new file mode 100644 index 000000000..f07a01ba5 --- /dev/null +++ b/horizon/conf/dash_template/templates/dash_name/base.html @@ -0,0 +1,11 @@ +{% load horizon %}{% jstemplate %}[% extends 'base.html' %] + +[% block sidebar %] + [% include 'horizon/common/_sidebar.html' %] +[% endblock %] + +[% block main %] + [% include "horizon/_messages.html" %] + [% block {{ dash_name }}_main %][% endblock %] +[% endblock %] +{% endjstemplate %} diff --git a/horizon/conf/panel_template/__init__.py b/horizon/conf/panel_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horizon/conf/panel_template/models.py b/horizon/conf/panel_template/models.py new file mode 100644 index 000000000..1b3d5f9ef --- /dev/null +++ b/horizon/conf/panel_template/models.py @@ -0,0 +1,3 @@ +""" +Stub file to work around django bug: https://code.djangoproject.com/ticket/7198 +""" diff --git a/horizon/conf/panel_template/panel.py b/horizon/conf/panel_template/panel.py new file mode 100644 index 000000000..3c1d85a9f --- /dev/null +++ b/horizon/conf/panel_template/panel.py @@ -0,0 +1,13 @@ +from django.utils.translation import ugettext_lazy as _ + +import horizon + +from {{ dash_path }} import dashboard + + +class {{ panel_name|title }}(horizon.Panel): + name = _("{{ panel_name|title }}") + slug = "{{ panel_name|slugify }}" + + +dashboard.register({{ panel_name|title }}) diff --git a/horizon/conf/panel_template/templates/panel_name/index.html b/horizon/conf/panel_template/templates/panel_name/index.html new file mode 100644 index 000000000..5185396b3 --- /dev/null +++ b/horizon/conf/panel_template/templates/panel_name/index.html @@ -0,0 +1,12 @@ +{% load horizon %}{% jstemplate %}[% extends '{{ dash_name }}/base.html' %] +[% load i18n %] +[% block title %][% trans "{{ panel_name|title }}" %][% endblock %] + +[% block page_header %] + [% include "horizon/common/_page_header.html" with title=_("{{ panel_name|title }}") %] +[% endblock page_header %] + +[% block {{ dash_name }}_main %] +[% endblock %] + +{% endjstemplate %} diff --git a/horizon/conf/panel_template/tests.py b/horizon/conf/panel_template/tests.py new file mode 100644 index 000000000..401c81c87 --- /dev/null +++ b/horizon/conf/panel_template/tests.py @@ -0,0 +1,7 @@ +from horizon import test + + +class {{ panel_name|title}}Tests(test.TestCase): + # Unit tests for {{ panel_name }}. + def test_me(self): + self.assertTrue(1 + 1 == 2) diff --git a/horizon/conf/panel_template/urls.py b/horizon/conf/panel_template/urls.py new file mode 100644 index 000000000..1ba0c0942 --- /dev/null +++ b/horizon/conf/panel_template/urls.py @@ -0,0 +1,7 @@ +from django.conf.urls.defaults import patterns, url + +from .views import IndexView + +urlpatterns = patterns('', + url(r'^$', IndexView.as_view(), name='index'), +) diff --git a/horizon/conf/panel_template/views.py b/horizon/conf/panel_template/views.py new file mode 100644 index 000000000..78cd9d30c --- /dev/null +++ b/horizon/conf/panel_template/views.py @@ -0,0 +1,10 @@ +from horizon import views + + +class IndexView(views.APIView): + # A very simple class-based view... + template_name = '{{ panel_name }}/index.html' + + def get_data(self, request, context, *args, **kwargs): + # Add data to the context here... + return context diff --git a/horizon/loaders.py b/horizon/loaders.py new file mode 100644 index 000000000..4cd3f559c --- /dev/null +++ b/horizon/loaders.py @@ -0,0 +1,46 @@ +""" +Wrapper for loading templates from "templates" directories in panel modules. +""" + +import os + +from django.conf import settings +from django.template.base import TemplateDoesNotExist +from django.template.loader import BaseLoader +from django.utils._os import safe_join + +# Set up a cache of the panel directories to search. +panel_template_dirs = {} + + +class TemplateLoader(BaseLoader): + is_usable = True + + def get_template_sources(self, template_name): + dash_name, panel_name, remainder = template_name.split(os.path.sep, 2) + key = os.path.join(dash_name, panel_name) + if key in panel_template_dirs: + template_dir = panel_template_dirs[key] + try: + yield safe_join(template_dir, panel_name, remainder) + except UnicodeDecodeError: + # The template dir name wasn't valid UTF-8. + raise + except ValueError: + # The joined path was located outside of template_dir. + pass + + def load_template_source(self, template_name, template_dirs=None): + for path in self.get_template_sources(template_name): + try: + file = open(path) + try: + return (file.read().decode(settings.FILE_CHARSET), path) + finally: + file.close() + except IOError: + pass + raise TemplateDoesNotExist(template_name) + + +_loader = TemplateLoader() diff --git a/horizon/management/__init__.py b/horizon/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horizon/management/commands/__init__.py b/horizon/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/horizon/management/commands/startdash.py b/horizon/management/commands/startdash.py new file mode 100644 index 000000000..e4f51f0ed --- /dev/null +++ b/horizon/management/commands/startdash.py @@ -0,0 +1,49 @@ +from optparse import make_option +import os + +from django.core.management.base import CommandError +from django.core.management.templates import TemplateCommand +from django.utils.importlib import import_module + +import horizon + + +class Command(TemplateCommand): + template = os.path.join(horizon.__path__[0], "conf", "dash_template") + option_list = TemplateCommand.option_list + ( + make_option('--target', + dest='target', + action='store', + default=None, + help='The directory in which the panel ' + 'should be created. Defaults to the ' + 'current directory. The value "auto" ' + 'may also be used to automatically ' + 'create the panel inside the specified ' + 'dashboard module.'),) + help = ("Creates a Django app directory structure for a new dashboard " + "with the given name in the current directory or optionally in " + "the given directory.") + + def handle(self, dash_name=None, **options): + if dash_name is None: + raise CommandError("You must provide a dashboard name.") + + # Use our default template if one isn't specified. + if not options.get("template", None): + options["template"] = self.template + + # We have html templates as well, so make sure those are included. + options["extensions"].extend(["html", "js", "css"]) + + # Check that the app_name cannot be imported. + try: + import_module(dash_name) + except ImportError: + pass + else: + raise CommandError("%r conflicts with the name of an existing " + "Python module and cannot be used as an app " + "name. Please try another name." % dash_name) + + super(Command, self).handle('dash', dash_name, **options) diff --git a/horizon/management/commands/startpanel.py b/horizon/management/commands/startpanel.py new file mode 100644 index 000000000..c320e2f65 --- /dev/null +++ b/horizon/management/commands/startpanel.py @@ -0,0 +1,89 @@ +from optparse import make_option +import os + +from django.core.management.base import CommandError +from django.core.management.templates import TemplateCommand +from django.utils.importlib import import_module + +import horizon + + +class Command(TemplateCommand): + args = "[name] [dashboard name] [optional destination directory]" + option_list = TemplateCommand.option_list + ( + make_option('--dashboard', '-d', + dest='dashboard', + action='store', + default=None, + help='The dotted python path to the ' + 'dashboard which this panel will be ' + 'registered with, e.g. ' + '"horizon.dashboards.syspanel".'), + make_option('--target', + dest='target', + action='store', + default=None, + help='The directory in which the panel ' + 'should be created. Defaults to the ' + 'current directory. The value "auto" ' + 'may also be used to automatically ' + 'create the panel inside the specified ' + 'dashboard module.'),) + template = os.path.join(horizon.__path__[0], "conf", "panel_template") + help = ("Creates a Django app directory structure for a new panel " + "with the given name in the current directory or optionally in " + "the given directory.") + + def handle(self, panel_name=None, **options): + if panel_name is None: + raise CommandError("You must provide a panel name.") + + if options.get('dashboard') is None: + raise CommandError("You must specify the name of the dashboard " + "this panel will be registered with using the " + "-d or --dashboard option.") + + dashboard_path = options.get('dashboard') + dashboard_mod_path = ".".join([dashboard_path, "dashboard"]) + + # Check the the dashboard.py file in the dashboard app can be imported. + # Add the dashboard information to our options to pass along if all + # goes well. + try: + dashboard_mod = import_module(dashboard_mod_path) + options["dash_path"] = dashboard_path + options["dash_name"] = dashboard_path.split(".")[-1] + except ImportError: + raise CommandError("A dashboard.py module could not be imported " + " from the dashboard at %r." + % options.get("dashboard")) + + target = options.pop("target", None) + if target == "auto": + target = os.path.join(os.path.dirname(dashboard_mod.__file__), + panel_name) + if not os.path.exists(target): + try: + os.mkdir(target) + except OSError, exc: + raise CommandError("Unable to create panel directory: %s" + % exc) + + # Use our default template if one isn't specified. + if not options.get("template", None): + options["template"] = self.template + + # We have html templates as well, so make sure those are included. + options["extensions"].extend(["html"]) + + # Check that the app_name cannot be imported. + try: + import_module(panel_name) + except ImportError: + pass + else: + raise CommandError("%r conflicts with the name of an existing " + "Python module and cannot be used as an app " + "name. Please try another name." % panel_name) + + super(Command, self).handle('panel', panel_name, target, **options) diff --git a/horizon/templatetags/horizon.py b/horizon/templatetags/horizon.py index 06bc5de83..4ae0ccdd9 100644 --- a/horizon/templatetags/horizon.py +++ b/horizon/templatetags/horizon.py @@ -132,13 +132,16 @@ class JSTemplateNode(template.Node): def render(self, context, ): output = self.nodelist.render(context) - return output.replace('[[', '{{').replace(']]', '}}') + output = output.replace('[[', '{{').replace(']]', '}}') + output = output.replace('[%', '{%').replace('%]', '%}') + return output @register.tag def jstemplate(parser, token): """ - Replaces ``[[`` and ``]]`` with ``{{`` and ``}}`` to avoid conflicts + Replaces ``[[`` and ``]]`` with ``{{`` and ``}}`` and + ``[%`` and ``%]`` with ``{%`` and ``%}`` to avoid conflicts with Django's template engine when using any of the Mustache-based templating libraries. """ diff --git a/horizon/tests/testsettings.py b/horizon/tests/testsettings.py index aba0aec57..b2b6d53c7 100644 --- a/horizon/tests/testsettings.py +++ b/horizon/tests/testsettings.py @@ -72,6 +72,7 @@ SITE_NAME = 'openstack' TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' NOSE_ARGS = ['--nocapture', '--nologcapture', + '--exclude-dir=horizon/conf/', '--cover-package=horizon', '--cover-inclusive'] diff --git a/openstack_dashboard/settings.py b/openstack_dashboard/settings.py index 59fc27fcc..1aa184740 100644 --- a/openstack_dashboard/settings.py +++ b/openstack_dashboard/settings.py @@ -78,7 +78,8 @@ TEMPLATE_CONTEXT_PROCESSORS = ( TEMPLATE_LOADERS = ( 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader' + 'django.template.loaders.app_directories.Loader', + 'horizon.loaders.TemplateLoader' ) TEMPLATE_DIRS = ( diff --git a/run_tests.sh b/run_tests.sh index aca7a8024..43a1076ce 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -6,7 +6,7 @@ set -o errexit # Increment me any time the environment should be rebuilt. # This includes dependncy changes, directory renames, etc. # Simple integer secuence: 1, 2, 3... -environment_version=16 +environment_version=17 #--------------------------------------------------------# function usage { @@ -21,7 +21,8 @@ function usage { echo " -f, --force Force a clean re-build of the virtual" echo " environment. Useful when dependencies have" echo " been added." - echo " -m, --makemessages Update all translation files." + echo " -m, --manage Run a Django management command." + echo " --makemessages Update all translation files." echo " -p, --pep8 Just run pep8" echo " -t, --tabs Check for tab characters in files." echo " -y, --pylint Just run pylint" @@ -68,6 +69,7 @@ selenium=0 testargs="" with_coverage=0 makemessages=0 +manage=0 # Jenkins sets a "JOB_NAME" variable, if it's not set, we'll make it "default" [ "$JOB_NAME" ] || JOB_NAME="default" @@ -83,7 +85,8 @@ function process_option { -t|--tabs) just_tabs=1;; -q|--quiet) quiet=1;; -c|--coverage) with_coverage=1;; - -m|--makemessages) makemessages=1;; + -m|--manage) manage=1;; + --makemessages) makemessages=1;; --with-selenium) selenium=1;; --docs) just_docs=1;; --runserver) runserver=1;; @@ -94,6 +97,10 @@ function process_option { esac } +function run_management_command { + ${command_wrapper} python $root/manage.py $testargs +} + function run_server { echo "Starting Django development server..." ${command_wrapper} python $root/manage.py runserver $testargs @@ -117,7 +124,7 @@ function run_pylint { function run_pep8 { echo "Running pep8 ..." rm -f pep8.txt - PEP8_EXCLUDE=vcsversion.py + PEP8_EXCLUDE=vcsversion.py,panel_template,dash_template PEP8_IGNORE=W602 PEP8_OPTIONS="--exclude=$PEP8_EXCLUDE --ignore=$PEP8_IGNORE --repeat" ${command_wrapper} pep8 $PEP8_OPTIONS $included_dirs | perl -ple 's/: ([WE]\d+)/: [$1]/' > pep8.txt || true @@ -188,6 +195,9 @@ function environment_check { 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 } @@ -346,6 +356,12 @@ 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 diff --git a/tools/test-requires b/tools/test-requires index da054d5ea..116ff8d6e 100644 --- a/tools/test-requires +++ b/tools/test-requires @@ -3,6 +3,7 @@ coverage django-nose mox nose +nose-exclude pep8 pylint distribute>=0.6.24