All of lore.kernel.org
 help / color / mirror / Atom feed
* [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable
@ 2015-12-18  9:23 Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 01/17] toaster: toastergui Use ToasterTable for projects page Elliot Smith
                   ` (17 more replies)
  0 siblings, 18 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Convert some of the most important Toaster pages to ToasterTable rather than
individual templates.

This involved a rewrite of the ToasterTable filter system (see tablefilter.py)
to cope with date range filtering for the builds tables.

I also moved a lot of code out of the views into the model, where it seems
better placed.

I also fixed a few JS issues which arose while testing, such as filters
not de-selecting when there are multiple filters on a table; event handlers
being lost when repopulating a table; and incorrect hiding of page elements
due to table classes being used for the page title element.

Related bug: https://bugzilla.yoctoproject.org/show_bug.cgi?id=8738

Also incidentally fixes https://bugzilla.yoctoproject.org/show_bug.cgi?id=8792,
as this functionality works correctly in ToasterTable.

Changes since 6d35574 (toaster-next) are in
git://git.yoctoproject.org/poky-contrib, elliot/toaster/tables-8738
http://git.yoctoproject.org/cgit.cgi/poky-contrib/log/?h=elliot/toaster/tables-8738

Elliot Smith (17):
  toaster: toastergui Use ToasterTable for projects page
  toaster: Move image file suffix list to model
  toaster: Check inferred file suffixes against list of known types
  toaster: toastergui Switch projects/ view to ToasterTable
  toaster: toastergui Use event delegates for hover help elements
  toaster: toastergui Convert all builds page to ToasterTable
  toaster: toastergui Add QuerysetFilter class
  toaster: toastergui Refactor ToasterTable filtering
  toaster: toastergui Switch off filter highlights when inactive
  toaster: toastergui Show recent builds on all builds page
  toaster: toastergui Implement date range filters for builds
  toaster: toastergui Implement "today" and "yesterday" filters
  toaster: toastergui Convert project builds page to ToasterTable
  toaster: tests Fix Django tests for new ToasterTable pages
  toaster: toastergui Don't hide all elements with .col class
  toaster: toastergui Ensure filter_value updates
  toaster: toastergui Streamline construction of filter objects

 bitbake/lib/toaster/orm/models.py                  | 109 ++-
 .../lib/toaster/toastergui/static/js/libtoaster.js |   6 +-
 .../toaster/toastergui/static/js/projecttopbar.js  |   9 +
 bitbake/lib/toaster/toastergui/static/js/table.js  | 272 +++++--
 bitbake/lib/toaster/toastergui/tablefilter.py      | 271 +++++++
 bitbake/lib/toaster/toastergui/tables.py           | 826 +++++++++++++++++++--
 .../toastergui/templates/baseprojectpage.html      |   1 +
 .../toastergui/templates/builds-toastertable.html  |  48 ++
 .../toaster/toastergui/templates/mrb_section.html  |   4 +-
 .../templates/projectbuilds-toastertable.html      |  56 ++
 .../templates/projects-toastertable.html           |  36 +
 .../toaster/toastergui/templates/toastertable.html |   7 +-
 bitbake/lib/toaster/toastergui/tests.py            | 255 ++++---
 bitbake/lib/toaster/toastergui/urls.py             |  14 +-
 bitbake/lib/toaster/toastergui/views.py            |  79 +-
 bitbake/lib/toaster/toastergui/widgets.py          |  94 ++-
 16 files changed, 1781 insertions(+), 306 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/tablefilter.py
 create mode 100644 bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
 create mode 100644 bitbake/lib/toaster/toastergui/templates/projectbuilds-toastertable.html
 create mode 100644 bitbake/lib/toaster/toastergui/templates/projects-toastertable.html

--
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply	[flat|nested] 19+ messages in thread

* [review-request][PATCH 01/17] toaster: toastergui Use ToasterTable for projects page
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 02/17] toaster: Move image file suffix list to model Elliot Smith
                   ` (16 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

The projects page uses the old approach for showing tables,
which means a template for each table. This means that applying
changes to ToasterTable (which is used for most tables) has
no effect on the layout, styling and behaviour for these older
tables, and requires additional duplicated effort.

Move the projects page to use ToasterTable instead, to remove
the duplication of effort.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/tables.py           | 226 ++++++++++++++++++++-
 .../templates/projects-toastertable.html           |  36 ++++
 bitbake/lib/toaster/toastergui/urls.py             |   6 +
 3 files changed, 265 insertions(+), 3 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/templates/projects-toastertable.html

diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index 3808820..e5cab48 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -21,13 +21,12 @@
 
 from toastergui.widgets import ToasterTable
 from orm.models import Recipe, ProjectLayer, Layer_Version, Machine, Project
-from orm.models import CustomImageRecipe, Package
-from django.db.models import Q, Max
+from orm.models import CustomImageRecipe, Package, Build
+from django.db.models import Q, Max, Count
 from django.conf.urls import url
 from django.core.urlresolvers import reverse
 from django.views.generic import TemplateView
 
-
 class ProjectFiltersMixin(object):
     """Common mixin for recipe, machine in project filters"""
 
@@ -633,3 +632,224 @@ class SelectPackagesTable(ToasterTable):
                         "the package content of you custom image",
                         static_data_name="add_rm_pkg_btn",
                         static_data_template='{% include "pkg_add_rm_btn.html" %}')
+
+class ProjectsTable(ToasterTable):
+    """Table of projects in Toaster"""
+
+    def __init__(self, *args, **kwargs):
+        super(ProjectsTable, self).__init__(*args, **kwargs)
+        self.default_orderby = 'updated'
+        self.title = 'All projects'
+        self.static_context_extra['Build'] = Build
+
+    def get_context_data(self, **kwargs):
+        return super(ProjectsTable, self).get_context_data(**kwargs)
+
+    def setup_queryset(self, *args, **kwargs):
+        queryset = Project.objects.all()
+
+        # annotate each project with its number of builds
+        queryset = queryset.annotate(num_builds=Count('build'))
+
+        # exclude the command line builds project if it has no builds
+        q_default_with_builds = Q(is_default=True) & Q(num_builds__gt=0)
+        queryset = queryset.filter(Q(is_default=False) |
+                                   q_default_with_builds)
+
+        # order rows
+        queryset = queryset.order_by(self.default_orderby)
+
+        self.queryset = queryset
+
+    # columns: last activity on (updated) - DEFAULT, project (name), release, machine, number of builds, last build outcome, recipe (name), errors, warnings, image files
+    def setup_columns(self, *args, **kwargs):
+        name_template = '''
+        {% load project_url_tag %}
+        <span data-project-field="name">
+          <a href="{% project_url data %}">
+            {{data.name}}
+          </a>
+        </span>
+        '''
+
+        last_activity_on_template = '''
+        {% load project_url_tag %}
+        <span data-project-field="updated">
+          <a href="{% project_url data %}">
+            {{data.updated | date:"d/m/y H:i"}}
+          </a>
+        </span>
+        '''
+
+        release_template = '''
+        <span data-project-field="release">
+          {% if data.release %}
+            <a href="{% url 'project' data.id %}#project-details">
+                {{data.release.name}}
+            </a>
+          {% elif data.is_default %}
+            <span class="muted">Not applicable</span>
+            <i class="icon-question-sign get-help hover-help"
+               data-original-title="This project does not have a release set.
+               It simply collects information about the builds you start from
+               the command line while Toaster is running"
+               style="visibility: hidden;">
+            </i>
+          {% else %}
+            No release available
+          {% endif %}
+        </span>
+        '''
+
+        machine_template = '''
+        <span data-project-field="machine">
+          {% if data.is_default %}
+            <span class="muted">Not applicable</span>
+            <i class="icon-question-sign get-help hover-help"
+               data-original-title="This project does not have a machine
+               set. It simply collects information about the builds you
+               start from the command line while Toaster is running"
+               style="visibility: hidden;"></i>
+          {% else %}
+            <a href="{% url 'project' data.id %}#machine-distro">
+              {{data.get_current_machine_name}}
+            </a>
+          {% endif %}
+        </span>
+        '''
+
+        number_of_builds_template = '''
+        {% if data.get_number_of_builds > 0 %}
+          <a href="{% url 'projectbuilds' data.id %}">
+            {{data.get_number_of_builds}}
+          </a>
+        {% else %}
+          <span class="muted">0</span>
+        {% endif %}
+        '''
+
+        last_build_outcome_template = '''
+        {% if data.get_number_of_builds > 0 %}
+          <a href="{% url 'builddashboard' data.get_last_build_id %}">
+            {% if data.get_last_outcome == extra.Build.SUCCEEDED %}
+              <i class="icon-ok-sign success"></i>
+            {% elif data.get_last_outcome == extra.Build.FAILED %}
+              <i class="icon-minus-sign error"></i>
+            {% endif %}
+          </a>
+        {% endif %}
+        '''
+
+        recipe_template = '''
+        {% if data.get_number_of_builds > 0 %}
+          <a href="{% url "builddashboard" data.get_last_build_id %}">
+            {{data.get_last_target}}
+          </a>
+        {% endif %}
+        '''
+
+        errors_template = '''
+        {% if data.get_number_of_builds > 0 %}
+          <a class="errors.count error"
+             href="{% url "builddashboard" data.get_last_build_id %}#errors">
+            {{data.get_last_errors}} error{{data.get_last_errors | pluralize}}
+          </a>
+        {% endif %}
+        '''
+
+        warnings_template = '''
+        {% if data.get_number_of_builds > 0 %}
+          <a class="warnings.count warning"
+             href="{% url "builddashboard" data.get_last_build_id %}#warnings">
+            {{data.get_last_warnings}} warning{{data.get_last_warnings | pluralize}}
+          </a>
+        {% endif %}
+        '''
+
+        image_files_template = '''
+        {% load projecttags %}
+        {% if data.get_number_of_builds > 0 and data.get_last_outcome == extra.Build.SUCCEEDED %}
+          <a href="{% url "builddashboard" data.get_last_build_id %}#images">
+            {{fstypes | get_dict_value:data.id}}
+          </a>
+        {% endif %}
+        '''
+
+        self.add_column(title='Project',
+                        hideable=False,
+                        orderable=True,
+                        static_data_name='name',
+                        static_data_template=name_template)
+
+        self.add_column(title='Last activity on',
+                        help_text='Starting date and time of the \
+                                   last project build. If the project has no \
+                                   builds, this shows the date the project was \
+                                   created.',
+                        hideable=True,
+                        orderable=True,
+                        static_data_name='updated',
+                        static_data_template=last_activity_on_template)
+
+        self.add_column(title='Release',
+                        help_text='The version of the build system used by \
+                                   the project',
+                        hideable=False,
+                        orderable=True,
+                        static_data_name='release',
+                        static_data_template=release_template)
+
+        self.add_column(title='Machine',
+                        help_text='The hardware currently selected for the \
+                                   project',
+                        hideable=False,
+                        orderable=False,
+                        static_data_name='machine',
+                        static_data_template=machine_template)
+
+        self.add_column(title='Number of builds',
+                        help_text='The number of builds which have been run \
+                                   for the project',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='number_of_builds',
+                        static_data_template=number_of_builds_template)
+
+        self.add_column(title='Last build outcome',
+                        help_text='Indicates whether the last project build \
+                                   completed successfully or failed',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='last_build_outcome',
+                        static_data_template=last_build_outcome_template)
+
+        self.add_column(title='Recipe',
+                        help_text='The last recipe which was built in this \
+                                   project',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='recipe_name',
+                        static_data_template=recipe_template)
+
+        self.add_column(title='Errors',
+                        help_text='The number of errors encountered during \
+                                   the last project build (if any)',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='errors',
+                        static_data_template=errors_template)
+
+        self.add_column(title='Warnings',
+                        help_text='The number of warnings encountered during \
+                                   the last project build (if any)',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='warnings',
+                        static_data_template=warnings_template)
+
+        self.add_column(title='Image files',
+                        help_text='',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='image_files',
+                        static_data_template=image_files_template)
diff --git a/bitbake/lib/toaster/toastergui/templates/projects-toastertable.html b/bitbake/lib/toaster/toastergui/templates/projects-toastertable.html
new file mode 100644
index 0000000..5814f32
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/templates/projects-toastertable.html
@@ -0,0 +1,36 @@
+{% extends 'base.html' %}
+
+{% block title %} All projects - Toaster {% endblock %}
+
+{% block pagecontent %}
+
+  <div class="page-header top-air">
+    <h1 data-role="page-title"></h1>
+  </div>
+
+  {% url 'projects' as xhr_table_url %}
+  {% include 'toastertable.html' %}
+
+  <script>
+    $(document).ready(function () {
+      var tableElt = $("#{{table_name}}");
+      var titleElt = $("[data-role='page-title']");
+
+      tableElt.on("table-done", function (e, total, tableParams) {
+        var title = "All projects";
+
+        if (tableParams.search || tableParams.filter) {
+          if (total === 0) {
+            title = "No projects found";
+          }
+          else if (total > 0) {
+            title = total + " project" + (total > 1 ? 's' : '') + " found";
+          }
+        }
+
+        titleElt.text(title);
+      });
+    });
+  </script>
+
+{% endblock %}
diff --git a/bitbake/lib/toaster/toastergui/urls.py b/bitbake/lib/toaster/toastergui/urls.py
index 2bf2d99..da97a31 100644
--- a/bitbake/lib/toaster/toastergui/urls.py
+++ b/bitbake/lib/toaster/toastergui/urls.py
@@ -75,8 +75,14 @@ urlpatterns = patterns('toastergui.views',
         url(r'^newproject/$', 'newproject', name='newproject'),
 
 
+        # TODO remove when new toaster table is ready
         url(r'^projects/$', 'projects', name='all-projects'),
 
+        # TODO move to /projects/ when new toaster table is ready
+        url(r'^projects-new/$',
+            tables.ProjectsTable.as_view(template_name="projects-toastertable.html"),
+            name='all-projects-new'),
+
         url(r'^project/(?P<pid>\d+)/$', 'project', name='project'),
         url(r'^project/(?P<pid>\d+)/configuration$', 'projectconf', name='projectconf'),
         url(r'^project/(?P<pid>\d+)/builds/$', 'projectbuilds', name='projectbuilds'),
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 02/17] toaster: Move image file suffix list to model
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 01/17] toaster: toastergui Use ToasterTable for projects page Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 03/17] toaster: Check inferred file suffixes against list of known types Elliot Smith
                   ` (15 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Image file suffixes are used in the project configuration page to
show a list of available image file types. This list is stored
as a function in the views code.

However, this list is also needed when parsing image file paths,
so that the suffixes can be shown in the "all builds" and "project
builds" tables.

Move the list of valid image file suffixes to the Target_Image_File
class to make is accessible in other places where it may be needed.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/orm/models.py       | 9 +++++++++
 bitbake/lib/toaster/toastergui/views.py | 7 +------
 2 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py
index 6e87c54..8d5f326 100644
--- a/bitbake/lib/toaster/orm/models.py
+++ b/bitbake/lib/toaster/orm/models.py
@@ -461,6 +461,15 @@ class Target(models.Model):
         return self.target
 
 class Target_Image_File(models.Model):
+    # valid suffixes for image files produced by a build
+    SUFFIXES = {
+        'btrfs', 'cpio', 'cpio.gz', 'cpio.lz4', 'cpio.lzma', 'cpio.xz',
+        'cramfs', 'elf', 'ext2', 'ext2.bz2', 'ext2.gz', 'ext2.lzma', 'ext4',
+        'ext4.gz', 'ext3', 'ext3.gz', 'hddimg', 'iso', 'jffs2', 'jffs2.sum',
+        'squashfs', 'squashfs-lzo', 'squashfs-xz', 'tar.bz2', 'tar.lz4',
+        'tar.xz', 'tartar.gz', 'ubi', 'ubifs', 'vmdk'
+    }
+
     target = models.ForeignKey(Target)
     file_name = models.FilePathField(max_length=254)
     file_size = models.IntegerField()
diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
index bd334b9..4cd7afd 100755
--- a/bitbake/lib/toaster/toastergui/views.py
+++ b/bitbake/lib/toaster/toastergui/views.py
@@ -2808,12 +2808,7 @@ if True:
             'all_proxy','ftp_proxy','http_proxy ','https_proxy'
             }
 
-        vars_fstypes  = {
-            'btrfs','cpio','cpio.gz','cpio.lz4','cpio.lzma','cpio.xz','cramfs',
-            'elf','ext2','ext2.bz2','ext2.gz','ext2.lzma', 'ext4', 'ext4.gz', 'ext3','ext3.gz','hddimg',
-            'iso','jffs2','jffs2.sum','squashfs','squashfs-lzo','squashfs-xz','tar.bz2',
-            'tar.lz4','tar.xz','tartar.gz','ubi','ubifs','vmdk'
-        }
+        vars_fstypes = Target_Image_File.SUFFIXES
 
         return(vars_managed,sorted(vars_fstypes),vars_blacklist)
 
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 03/17] toaster: Check inferred file suffixes against list of known types
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 01/17] toaster: toastergui Use ToasterTable for projects page Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 02/17] toaster: Move image file suffix list to model Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 04/17] toaster: toastergui Switch projects/ view to ToasterTable Elliot Smith
                   ` (14 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

The algorithm for finding the suffix for image files produced by
the build doesn't reference a list of known file suffixes, so
could be prone to error.

Modify how file suffixes are parsed from the file path so that
they are compared against a list of known types; if this fails,
use the part of the basename of the file path after the first
'.' character.

Also rationalise the places in the views code where we
extract the file name extensions for builds, so they both use
the same algorithm (before, the same code was duplicated in
two places).

[YOCTO #8417]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/orm/models.py        | 68 ++++++++++++++++++++++++++++++--
 bitbake/lib/toaster/toastergui/tables.py | 10 +++--
 bitbake/lib/toaster/toastergui/views.py  | 38 ++----------------
 3 files changed, 75 insertions(+), 41 deletions(-)

diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py
index 8d5f326..052dbae 100644
--- a/bitbake/lib/toaster/orm/models.py
+++ b/bitbake/lib/toaster/orm/models.py
@@ -29,6 +29,9 @@ from django.core import validators
 from django.conf import settings
 import django.db.models.signals
 
+import os.path
+import re
+
 import logging
 logger = logging.getLogger("toaster")
 
@@ -234,6 +237,14 @@ class Project(models.Model):
         except (Build.DoesNotExist,IndexError):
             return( "not_found" )
 
+    def get_last_build_extensions(self):
+        """
+        Get list of file name extensions for images produced by the most
+        recent build
+        """
+        last_build = Build.objects.get(pk = self.get_last_build_id())
+        return last_build.get_image_file_extensions()
+
     def get_last_imgfiles(self):
         build_id = self.get_last_build_id
         if (-1 == build_id):
@@ -376,6 +387,57 @@ class Build(models.Model):
             eta += ((eta - self.started_on)*(100-completeper))/completeper
         return eta
 
+    def get_image_file_extensions(self):
+        """
+        Get list of file name extensions for images produced by this build
+        """
+        targets = Target.objects.filter(build_id = self.id)
+        extensions = []
+
+        # pattern to match against file path for building extension string
+        pattern = re.compile('\.([^\.]+?)$')
+
+        for target in targets:
+            if (not target.is_image):
+                continue
+
+            target_image_files = Target_Image_File.objects.filter(target_id = target.id)
+
+            for target_image_file in target_image_files:
+                file_name = os.path.basename(target_image_file.file_name)
+                suffix = ''
+
+                continue_matching = True
+
+                # incrementally extract the suffix from the file path,
+                # checking it against the list of valid suffixes at each
+                # step; if the path is stripped of all potential suffix
+                # parts without matching a valid suffix, this returns all
+                # characters after the first '.' in the file name
+                while continue_matching:
+                    matches = pattern.search(file_name)
+
+                    if None == matches:
+                        continue_matching = False
+                        suffix = re.sub('^\.', '', suffix)
+                        continue
+                    else:
+                        suffix = matches.group(1) + suffix
+
+                    if suffix in Target_Image_File.SUFFIXES:
+                        continue_matching = False
+                        continue
+                    else:
+                        # reduce the file name and try to find the next
+                        # segment from the path which might be part
+                        # of the suffix
+                        file_name = re.sub('.' + matches.group(1), '', file_name)
+                        suffix = '.' + suffix
+
+                if not suffix in extensions:
+                    extensions.append(suffix)
+
+        return ', '.join(extensions)
 
     def get_sorted_target_list(self):
         tgts = Target.objects.filter(build_id = self.id).order_by( 'target' );
@@ -418,7 +480,7 @@ class Build(models.Model):
             return self.get_outcome_text()
 
     def __str__(self):
-        return "%s %s %s" % (self.id, self.project, ",".join([t.target for t in self.target_set.all()]))
+        return "%d %s %s" % (self.id, self.project, ",".join([t.target for t in self.target_set.all()]))
 
 
 # an Artifact is anything that results from a Build, and may be of interest to the user, and is not stored elsewhere
@@ -609,7 +671,7 @@ class Task(models.Model):
     sstate_text  = property(get_sstate_text)
 
     def __unicode__(self):
-        return "%s(%s) %s:%s" % (self.pk, self.build.pk, self.recipe.name, self.task_name)
+        return "%d(%d) %s:%s" % (self.pk, self.build.pk, self.recipe.name, self.task_name)
 
     class Meta:
         ordering = ('order', 'recipe' ,)
@@ -1260,7 +1322,7 @@ class Layer_Version(models.Model):
         return sorted(result, key=lambda x: x.layer.name)
 
     def __unicode__(self):
-        return "%s %s (VCS %s, Project %s)" % (self.pk, str(self.layer), self.get_vcs_reference(), self.build.project if self.build is not None else "No project")
+        return "%d %s (VCS %s, Project %s)" % (self.pk, str(self.layer), self.get_vcs_reference(), self.build.project if self.build is not None else "No project")
 
     class Meta:
         unique_together = ("layer_source", "up_id")
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index e5cab48..0b1d8a2 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -661,7 +661,9 @@ class ProjectsTable(ToasterTable):
 
         self.queryset = queryset
 
-    # columns: last activity on (updated) - DEFAULT, project (name), release, machine, number of builds, last build outcome, recipe (name), errors, warnings, image files
+    # columns: last activity on (updated) - DEFAULT, project (name), release,
+    # machine, number of builds, last build outcome, recipe (name), errors,
+    # warnings, image files
     def setup_columns(self, *args, **kwargs):
         name_template = '''
         {% load project_url_tag %}
@@ -767,10 +769,9 @@ class ProjectsTable(ToasterTable):
         '''
 
         image_files_template = '''
-        {% load projecttags %}
         {% if data.get_number_of_builds > 0 and data.get_last_outcome == extra.Build.SUCCEEDED %}
           <a href="{% url "builddashboard" data.get_last_build_id %}#images">
-            {{fstypes | get_dict_value:data.id}}
+            {{data.get_last_build_extensions}}
           </a>
         {% endif %}
         '''
@@ -848,7 +849,8 @@ class ProjectsTable(ToasterTable):
                         static_data_template=warnings_template)
 
         self.add_column(title='Image files',
-                        help_text='',
+                        help_text='The root file system types produced by \
+                                   the last project build',
                         hideable=True,
                         orderable=False,
                         static_data_name='image_files',
diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
index 4cd7afd..8148623 100755
--- a/bitbake/lib/toaster/toastergui/views.py
+++ b/bitbake/lib/toaster/toastergui/views.py
@@ -2008,23 +2008,10 @@ if True:
         context_date,today_begin,yesterday_begin = _add_daterange_context(queryset_all, request, {'started_on','completed_on'})
 
         # set up list of fstypes for each build
-        fstypes_map = {};
+        fstypes_map = {}
+
         for build in build_info:
-            targets = Target.objects.filter( build_id = build.id )
-            comma = "";
-            extensions = "";
-            for t in targets:
-                if ( not t.is_image ):
-                    continue
-                tif = Target_Image_File.objects.filter( target_id = t.id )
-                for i in tif:
-                    s=re.sub('.*tar.bz2', 'tar.bz2', i.file_name)
-                    if s == i.file_name:
-                        s=re.sub('.*\.', '', i.file_name)
-                    if None == re.search(s,extensions):
-                        extensions += comma + s
-                        comma = ", "
-            fstypes_map[build.id]=extensions
+            fstypes_map[build.id] = build.get_image_file_extensions()
 
         # send the data to the template
         context = {
@@ -3047,24 +3034,7 @@ if True:
         # translate the project's build target strings
         fstypes_map = {};
         for project in project_info:
-            try:
-                targets = Target.objects.filter( build_id = project.get_last_build_id() )
-                comma = "";
-                extensions = "";
-                for t in targets:
-                    if ( not t.is_image ):
-                        continue
-                    tif = Target_Image_File.objects.filter( target_id = t.id )
-                    for i in tif:
-                        s=re.sub('.*tar.bz2', 'tar.bz2', i.file_name)
-                        if s == i.file_name:
-                            s=re.sub('.*\.', '', i.file_name)
-                        if None == re.search(s,extensions):
-                            extensions += comma + s
-                            comma = ", "
-                fstypes_map[project.id]=extensions
-            except (Target.DoesNotExist,IndexError):
-                fstypes_map[project.id]=project.get_last_imgfiles
+            fstypes_map[project.id] = project.get_last_build_extensions()
 
         context = {
                 'mru' : build_mru,
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 04/17] toaster: toastergui Switch projects/ view to ToasterTable
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (2 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 03/17] toaster: Check inferred file suffixes against list of known types Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 05/17] toaster: toastergui Use event delegates for hover help elements Elliot Smith
                   ` (13 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Remove the old projects page and replace with the new
ToasterTable-based version.

NB although the projects.html template is no longer required,
it's been left in as there will be changes applied to it for
the new theme. These changes will have to then be transferred
from the projects.html template to projects-toastertable.html.

Similarly, the code for the projects page in views.py has been
retained.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/urls.py  |  9 ++-------
 bitbake/lib/toaster/toastergui/views.py | 24 ++++++++++++++++++++----
 2 files changed, 22 insertions(+), 11 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/urls.py b/bitbake/lib/toaster/toastergui/urls.py
index da97a31..b5e9a05 100644
--- a/bitbake/lib/toaster/toastergui/urls.py
+++ b/bitbake/lib/toaster/toastergui/urls.py
@@ -74,14 +74,9 @@ urlpatterns = patterns('toastergui.views',
         # project URLs
         url(r'^newproject/$', 'newproject', name='newproject'),
 
-
-        # TODO remove when new toaster table is ready
-        url(r'^projects/$', 'projects', name='all-projects'),
-
-        # TODO move to /projects/ when new toaster table is ready
-        url(r'^projects-new/$',
+        url(r'^projects/$',
             tables.ProjectsTable.as_view(template_name="projects-toastertable.html"),
-            name='all-projects-new'),
+            name='all-projects'),
 
         url(r'^project/(?P<pid>\d+)/$', 'project', name='project'),
         url(r'^project/(?P<pid>\d+)/configuration$', 'projectconf', name='projectconf'),
diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
index 8148623..a79261d 100755
--- a/bitbake/lib/toaster/toastergui/views.py
+++ b/bitbake/lib/toaster/toastergui/views.py
@@ -2990,9 +2990,7 @@ if True:
             }
             return render(request, "unavailable_artifact.html", context)
 
-
-
-
+    """
     @_template_renderer("projects.html")
     def projects(request):
         (pagesize, orderby) = _get_parameters_values(request, 10, 'updated:-')
@@ -3034,7 +3032,24 @@ if True:
         # translate the project's build target strings
         fstypes_map = {};
         for project in project_info:
-            fstypes_map[project.id] = project.get_last_build_extensions()
+            try:
+                targets = Target.objects.filter( build_id = project.get_last_build_id() )
+                comma = "";
+                extensions = "";
+                for t in targets:
+                    if ( not t.is_image ):
+                        continue
+                    tif = Target_Image_File.objects.filter( target_id = t.id )
+                    for i in tif:
+                        s=re.sub('.*tar.bz2', 'tar.bz2', i.file_name)
+                        if s == i.file_name:
+                            s=re.sub('.*\.', '', i.file_name)
+                        if None == re.search(s,extensions):
+                            extensions += comma + s
+                            comma = ", "
+                fstypes_map[project.id]=extensions
+            except (Target.DoesNotExist,IndexError):
+                fstypes_map[project.id]=project.get_last_imgfiles
 
         context = {
                 'mru' : build_mru,
@@ -3092,3 +3107,4 @@ if True:
 
         _set_parameters_values(pagesize, orderby, request)
         return context
+    """
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 05/17] toaster: toastergui Use event delegates for hover help elements
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (3 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 04/17] toaster: toastergui Switch projects/ view to ToasterTable Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 06/17] toaster: toastergui Convert all builds page to ToasterTable Elliot Smith
                   ` (12 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

libtoaster.js binds to hover help elements via their hover() and
mouseout() methods. However, any elements added to the DOM after
libtoaster has initialised will not have these bindings added.
This causes a problem for ToasterTables which have hover-help
elements (e.g. the builds/ table).

Use the on() method instead. This uses event delegation to bind
a handler to any th or td elements already in the DOM, or
which will be added to the DOM in future. ToasterTables can
now reconstruct the table DOM and still have the correct handlers
attached once the table is done.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/static/js/libtoaster.js | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
index c04f7ab..1012034 100644
--- a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
+++ b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
@@ -448,10 +448,12 @@ $(document).ready(function() {
 
     // show help bubble only on hover inside tables
     $(".hover-help").css("visibility","hidden");
-    $("th, td").hover(function () {
+
+    $("table").on("mouseover", "th, td", function () {
         $(this).find(".hover-help").css("visibility","visible");
     });
-    $("th, td").mouseleave(function () {
+
+    $("table").on("mouseleave", "th, td", function () {
         $(this).find(".hover-help").css("visibility","hidden");
     });
 
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 06/17] toaster: toastergui Convert all builds page to ToasterTable
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (4 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 05/17] toaster: toastergui Use event delegates for hover help elements Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 07/17] toaster: toastergui Add QuerysetFilter class Elliot Smith
                   ` (11 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

For better long-term maintainability, use ToasterTable instead
of Django template and view code to display the all builds page.

NB the builds.html template has been left in, as this will
otherwise cause conflicts when merging the new theme.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/orm/models.py                  |  32 ++-
 bitbake/lib/toaster/toastergui/tables.py           | 311 ++++++++++++++++++++-
 .../toastergui/templates/builds-toastertable.html  |  62 ++++
 bitbake/lib/toaster/toastergui/urls.py             |   5 +-
 bitbake/lib/toaster/toastergui/views.py            |  32 ---
 5 files changed, 407 insertions(+), 35 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/templates/builds-toastertable.html

diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py
index 052dbae..933527d 100644
--- a/bitbake/lib/toaster/orm/models.py
+++ b/bitbake/lib/toaster/orm/models.py
@@ -447,6 +447,12 @@ class Build(models.Model):
         return Build.BUILD_OUTCOME[int(self.outcome)][1]
 
     @property
+    def failed_tasks(self):
+        """ Get failed tasks for the build """
+        tasks = self.task_build.all()
+        return tasks.filter(order__gt=0, outcome=Task.OUTCOME_FAILED)
+
+    @property
     def errors(self):
         return (self.logmessage_set.filter(level=LogMessage.ERROR) |
                 self.logmessage_set.filter(level=LogMessage.EXCEPTION) |
@@ -457,8 +463,32 @@ class Build(models.Model):
         return self.logmessage_set.filter(level=LogMessage.WARNING)
 
     @property
+    def timespent(self):
+        return self.completed_on - self.started_on
+
+    @property
     def timespent_seconds(self):
-        return (self.completed_on - self.started_on).total_seconds()
+        return self.timespent.total_seconds()
+
+    @property
+    def target_labels(self):
+        """
+        Sorted (a-z) "target1:task, target2, target3" etc. string for all
+        targets in this build
+        """
+        targets = self.target_set.all()
+        target_labels = []
+        target_label = None
+
+        for target in targets:
+            target_label = target.target
+            if target.task:
+                target_label = target_label + ':' + target.task
+            target_labels.append(target_label)
+
+        target_labels.sort()
+
+        return target_labels
 
     def get_current_status(self):
         """
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index 0b1d8a2..0639b00 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -21,7 +21,7 @@
 
 from toastergui.widgets import ToasterTable
 from orm.models import Recipe, ProjectLayer, Layer_Version, Machine, Project
-from orm.models import CustomImageRecipe, Package, Build
+from orm.models import CustomImageRecipe, Package, Build, LogMessage, Task
 from django.db.models import Q, Max, Count
 from django.conf.urls import url
 from django.core.urlresolvers import reverse
@@ -855,3 +855,312 @@ class ProjectsTable(ToasterTable):
                         orderable=False,
                         static_data_name='image_files',
                         static_data_template=image_files_template)
+
+class BuildsTable(ToasterTable):
+    """Table of builds in Toaster"""
+
+    def __init__(self, *args, **kwargs):
+        super(BuildsTable, self).__init__(*args, **kwargs)
+        self.default_orderby = '-completed_on'
+        self.title = 'All builds'
+        self.static_context_extra['Build'] = Build
+        self.static_context_extra['Task'] = Task
+
+    def get_context_data(self, **kwargs):
+        return super(BuildsTable, self).get_context_data(**kwargs)
+
+    def setup_queryset(self, *args, **kwargs):
+        queryset = Build.objects.all()
+
+        # don't include in progress builds
+        queryset = queryset.exclude(outcome=Build.IN_PROGRESS)
+
+        # sort
+        queryset = queryset.order_by(self.default_orderby)
+
+        # annotate with number of ERROR and EXCEPTION log messages
+        queryset = queryset.annotate(
+            errors_no = Count(
+                'logmessage',
+                only = Q(logmessage__level=LogMessage.ERROR) |
+                       Q(logmessage__level=LogMessage.EXCEPTION)
+            )
+        )
+
+        # annotate with number of WARNING log messages
+        queryset = queryset.annotate(
+            warnings_no = Count(
+                'logmessage',
+                only = Q(logmessage__level=LogMessage.WARNING)
+            )
+        )
+
+        self.queryset = queryset
+
+    def setup_columns(self, *args, **kwargs):
+        outcome_template = '''
+        <a href="{% url "builddashboard" data.id %}">
+            {% if data.outcome == data.SUCCEEDED %}
+                <i class="icon-ok-sign success"></i>
+            {% elif data.outcome == data.FAILED %}
+                <i class="icon-minus-sign error"></i>
+            {% endif %}
+        </a>
+
+        {% if data.cooker_log_path %}
+            &nbsp;
+            <a href="{% url "build_artifact" data.id "cookerlog" data.id %}">
+               <i class="icon-download-alt" title="Download build log"></i>
+            </a>
+        {% endif %}
+        '''
+
+        recipe_template = '''
+        {% for target_label in data.target_labels %}
+            <a href="{% url "builddashboard" data.id %}">
+                {{target_label}}
+            </a>
+            <br />
+        {% endfor %}
+        '''
+
+        machine_template = '''
+        <a href="{% url "builddashboard" data.id %}">
+            {{data.machine}}
+        </a>
+        '''
+
+        started_on_template = '''
+        <a href="{% url "builddashboard" data.id %}">
+            {{data.started_on | date:"d/m/y H:i"}}
+        </a>
+        '''
+
+        completed_on_template = '''
+        <a href="{% url "builddashboard" data.id %}">
+            {{data.completed_on | date:"d/m/y H:i"}}
+        </a>
+        '''
+
+        failed_tasks_template = '''
+        {% if data.failed_tasks.count == 1 %}
+            <a href="{% url "task" data.id data.failed_tasks.0.id %}">
+                <span class="error">
+                    {{data.failed_tasks.0.recipe.name}}.{{data.failed_tasks.0.task_name}}
+                </span>
+            </a>
+            <a href="{% url "build_artifact" data.id "tasklogfile" data.failed_tasks.0.id %}">
+                <i class="icon-download-alt"
+                   data-original-title="Download task log file">
+                </i>
+            </a>
+        {% elif data.failed_tasks.count > 1 %}
+            <a href="{% url "tasks" data.id %}?filter=outcome%3A{{extra.Task.OUTCOME_FAILED}}">
+                <span class="error">{{data.failed_tasks.count}} tasks</span>
+            </a>
+        {% endif %}
+        '''
+
+        errors_template = '''
+        {% if data.errors.count %}
+            <a class="errors.count error" href="{% url "builddashboard" data.id %}#errors">
+                {{data.errors.count}} error{{data.errors.count|pluralize}}
+            </a>
+        {% endif %}
+        '''
+
+        warnings_template = '''
+        {% if data.warnings.count %}
+            <a class="warnings.count warning" href="{% url "builddashboard" data.id %}#warnings">
+                {{data.warnings.count}} warning{{data.warnings.count|pluralize}}
+            </a>
+        {% endif %}
+        '''
+
+        time_template = '''
+        {% load projecttags %}
+        <a href="{% url "buildtime" data.id %}">
+            {{data.timespent_seconds | sectohms}}
+        </a>
+        '''
+
+        image_files_template = '''
+        {% if data.outcome == extra.Build.SUCCEEDED %}
+          <a href="{% url "builddashboard" data.id %}#images">
+            {{data.get_image_file_extensions}}
+          </a>
+        {% endif %}
+        '''
+
+        project_template = '''
+        {% load project_url_tag %}
+        <a href="{% project_url data.project %}">
+            {{data.project.name}}
+        </a>
+        {% if data.project.is_default %}
+            <i class="icon-question-sign get-help hover-help" title=""
+               data-original-title="This project shows information about
+               the builds you start from the command line while Toaster is
+               running" style="visibility: hidden;"></i>
+        {% endif %}
+        '''
+
+        self.add_column(title='Outcome',
+                        help_text='Final state of the build (successful \
+                                   or failed)',
+                        hideable=False,
+                        orderable=True,
+                        filter_name='outcome_filter',
+                        static_data_name='outcome',
+                        static_data_template=outcome_template)
+
+        self.add_column(title='Recipe',
+                        help_text='What was built (i.e. one or more recipes \
+                                   or image recipes)',
+                        hideable=False,
+                        orderable=False,
+                        static_data_name='target',
+                        static_data_template=recipe_template)
+
+        self.add_column(title='Machine',
+                        help_text='Hardware for which you are building a \
+                                   recipe or image recipe',
+                        hideable=False,
+                        orderable=True,
+                        static_data_name='machine',
+                        static_data_template=machine_template)
+
+        self.add_column(title='Started on',
+                        help_text='The date and time when the build started',
+                        hideable=True,
+                        orderable=True,
+                        static_data_name='started_on',
+                        static_data_template=started_on_template)
+
+        self.add_column(title='Completed on',
+                        help_text='The date and time when the build finished',
+                        hideable=False,
+                        orderable=True,
+                        static_data_name='completed_on',
+                        static_data_template=completed_on_template)
+
+        self.add_column(title='Failed tasks',
+                        help_text='The number of tasks which failed during \
+                                   the build',
+                        hideable=True,
+                        orderable=False,
+                        filter_name='failed_tasks_filter',
+                        static_data_name='failed_tasks',
+                        static_data_template=failed_tasks_template)
+
+        self.add_column(title='Errors',
+                        help_text='The number of errors encountered during \
+                                   the build (if any)',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='errors',
+                        static_data_template=errors_template)
+
+        self.add_column(title='Warnings',
+                        help_text='The number of warnings encountered during \
+                                   the build (if any)',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='warnings',
+                        static_data_template=warnings_template)
+
+        self.add_column(title='Time',
+                        help_text='How long the build took to finish',
+                        hideable=False,
+                        orderable=False,
+                        static_data_name='time',
+                        static_data_template=time_template)
+
+        self.add_column(title='Image files',
+                        help_text='The root file system types produced by \
+                                   the build',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='image_files',
+                        static_data_template=image_files_template)
+
+        self.add_column(title='Project',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='project-name',
+                        static_data_template=project_template)
+
+    def filter_only_failed_builds(self, count_only=False):
+        """ Only show builds with failed outcome """
+        query = self.queryset.filter(outcome=Build.FAILED)
+        if count_only:
+            return query.count()
+
+        self.queryset = query
+
+    def filter_only_successful_builds(self, count_only=False):
+        """ Only show builds with successful outcome """
+        query = self.queryset.filter(outcome=Build.SUCCEEDED)
+        if count_only:
+            return query.count()
+
+        self.queryset = query
+
+    def filter_only_builds_with_failed_tasks(self, count_only=False):
+        """ Only show builds with failed tasks """
+        query = self.queryset.filter(task_build__outcome=Task.OUTCOME_FAILED)
+
+        if count_only:
+            return query.count()
+
+        self.queryset = query
+
+    def filter_only_builds_without_failed_tasks(self, count_only=False):
+        """ Only show builds without failed tasks """
+        query = self.queryset.filter(~Q(task_build__outcome=Task.OUTCOME_FAILED))
+
+        if count_only:
+            return query.count()
+
+        self.queryset = query
+
+    def setup_filters(self, *args, **kwargs):
+        # outcomes
+        successful_builds_filter = self.make_filter_action(
+            'successful_builds',
+            'Successful builds',
+            self.filter_only_successful_builds
+        )
+
+        failed_builds_filter = self.make_filter_action(
+            'failed_builds',
+            'Failed builds',
+            self.filter_only_failed_builds
+        )
+
+        self.add_filter(title='Filter builds by outcome',
+                        name='outcome_filter',
+                        filter_actions = [
+                            successful_builds_filter,
+                            failed_builds_filter
+                        ])
+
+        # failed tasks
+        with_failed_tasks_filter = self.make_filter_action(
+            'with_failed_tasks',
+            'Builds with failed tasks',
+            self.filter_only_builds_with_failed_tasks
+        )
+
+        without_failed_tasks_filter = self.make_filter_action(
+            'without_failed_tasks',
+            'Builds without failed tasks',
+            self.filter_only_builds_without_failed_tasks
+        )
+
+        self.add_filter(title='Filter builds by failed tasks',
+                        name='failed_tasks_filter',
+                        filter_actions = [
+                            with_failed_tasks_filter,
+                            without_failed_tasks_filter
+                        ])
diff --git a/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
new file mode 100644
index 0000000..419d2b5
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
@@ -0,0 +1,62 @@
+{% extends 'base.html' %}
+
+{% block title %} All builds - Toaster {% endblock %}
+
+{% block pagecontent %}
+  <div class="page-header top-air">
+    <h1 data-role="page-title"></h1>
+  </div>
+
+  <div class="row-fluid">
+    {# TODO need to pass this data to context #}
+    {#% include 'mrb_section.html' %#}
+
+    {% url 'builds' as xhr_table_url %}
+    {% include 'toastertable.html' %}
+  </div>
+
+  <script>
+    $(document).ready(function () {
+      var tableElt = $("#{{table_name}}");
+      var titleElt = $("[data-role='page-title']");
+
+      tableElt.on("table-done", function (e, total, tableParams) {
+        var title = "All builds";
+
+        if (tableParams.search || tableParams.filter) {
+          if (total === 0) {
+            title = "No builds found";
+          }
+          else if (total > 0) {
+            title = total + " build" + (total > 1 ? 's' : '') + " found";
+          }
+        }
+
+        titleElt.text(title);
+      });
+
+      /* {% if last_date_from and last_date_to %}
+      // TODO initialize the date range controls;
+      // this will need to be added via ToasterTable
+      date_init(
+        "started_on",
+        "{{last_date_from}}",
+        "{{last_date_to}}",
+        "{{dateMin_started_on}}",
+        "{{dateMax_started_on}}",
+        "{{daterange_selected}}"
+      );
+
+      date_init(
+        "completed_on",
+        "{{last_date_from}}",
+        "{{last_date_to}}",
+        "{{dateMin_completed_on}}",
+        "{{dateMax_completed_on}}",
+        "{{daterange_selected}}"
+      );
+      {% endif %}
+      */
+    });
+  </script>
+{% endblock %}
diff --git a/bitbake/lib/toaster/toastergui/urls.py b/bitbake/lib/toaster/toastergui/urls.py
index b5e9a05..707b7d5 100644
--- a/bitbake/lib/toaster/toastergui/urls.py
+++ b/bitbake/lib/toaster/toastergui/urls.py
@@ -27,7 +27,10 @@ urlpatterns = patterns('toastergui.views',
         # landing page
         url(r'^landing/$', 'landing', name='landing'),
 
-        url(r'^builds/$', 'builds', name='all-builds'),
+        url(r'^builds/$',
+            tables.BuildsTable.as_view(template_name="builds-toastertable.html"),
+            name='all-builds'),
+
         # build info navigation
         url(r'^build/(?P<build_id>\d+)$', 'builddashboard', name="builddashboard"),
 
diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
index a79261d..295773f 100755
--- a/bitbake/lib/toaster/toastergui/views.py
+++ b/bitbake/lib/toaster/toastergui/views.py
@@ -1915,34 +1915,6 @@ if True:
         ''' The exception raised on invalid POST requests '''
         pass
 
-    # shows the "all builds" page for managed mode; it displays build requests (at least started!) instead of actual builds
-    # WARNING _build_list_helper() may raise a RedirectException, which
-    # will set the GET parameters and redirect back to the
-    # all-builds or projectbuilds page as appropriate;
-    # TODO don't use exceptions to control program flow
-    @_template_renderer("builds.html")
-    def builds(request):
-        # define here what parameters the view needs in the GET portion in order to
-        # be able to display something.  'count' and 'page' are mandatory for all views
-        # that use paginators.
-
-        queryset = Build.objects.all()
-
-        redirect_page = resolve(request.path_info).url_name
-
-        context, pagesize, orderby = _build_list_helper(request,
-                                                        queryset,
-                                                        redirect_page)
-        # all builds page as a Project column
-        context['tablecols'].append({
-            'name': 'Project',
-            'clclass': 'project_column'
-        })
-
-        _set_parameters_values(pagesize, orderby, request)
-        return context
-
-
     # helper function, to be used on "all builds" and "project builds" pages
     def _build_list_helper(request, queryset_all, redirect_page, pid=None):
         default_orderby = 'completed_on:-'
@@ -1986,10 +1958,6 @@ if True:
             warnings_no = Count('logmessage', only=q_warnings)
         )
 
-        # add timespent field
-        timespent = 'completed_on - started_on'
-        queryset_all = queryset_all.extra(select={'timespent': timespent})
-
         queryset_with_search = _get_queryset(Build, queryset_all,
                                              None, search_term,
                                              ordering_string, '-completed_on')
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 07/17] toaster: toastergui Add QuerysetFilter class
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (5 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 06/17] toaster: toastergui Convert all builds page to ToasterTable Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 08/17] toaster: toastergui Refactor ToasterTable filtering Elliot Smith
                   ` (10 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Filters are added to tables by passing a list of function to the
ToasterTable.add_filters() method. This means a lot of code is
duplicated.

Add a QuerysetFilter class which encapsulates a filter for a
Queryset and provides a generic count() method.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/querysetfilter.py |  24 +++++
 bitbake/lib/toaster/toastergui/tables.py         | 108 +++++++----------------
 bitbake/lib/toaster/toastergui/widgets.py        |  16 ++--
 3 files changed, 65 insertions(+), 83 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/querysetfilter.py

diff --git a/bitbake/lib/toaster/toastergui/querysetfilter.py b/bitbake/lib/toaster/toastergui/querysetfilter.py
new file mode 100644
index 0000000..62297e9
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/querysetfilter.py
@@ -0,0 +1,24 @@
+class QuerysetFilter(object):
+    """ Filter for a queryset """
+
+    def __init__(self, criteria=None):
+        if criteria:
+            self.set_criteria(criteria)
+
+    def set_criteria(self, criteria):
+        """
+        criteria is an instance of django.db.models.Q;
+        see https://docs.djangoproject.com/en/1.9/ref/models/querysets/#q-objects
+        """
+        self.criteria = criteria
+
+    def filter(self, queryset):
+        """
+        Filter queryset according to the criteria for this filter,
+        returning the filtered queryset
+        """
+        return queryset.filter(self.criteria)
+
+    def count(self, queryset):
+        """ Returns a count of the elements in the filtered queryset """
+        return self.filter(queryset).count()
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index 0639b00..a49e45c 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -20,6 +20,7 @@
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
 from toastergui.widgets import ToasterTable
+from toastergui.querysetfilter import QuerysetFilter
 from orm.models import Recipe, ProjectLayer, Layer_Version, Machine, Project
 from orm.models import CustomImageRecipe, Package, Build, LogMessage, Task
 from django.db.models import Q, Max, Count
@@ -27,22 +28,10 @@ from django.conf.urls import url
 from django.core.urlresolvers import reverse
 from django.views.generic import TemplateView
 
-class ProjectFiltersMixin(object):
-    """Common mixin for recipe, machine in project filters"""
-
-    def filter_in_project(self, count_only=False):
-        query = self.queryset.filter(layer_version__in=self.project_layers)
-        if count_only:
-            return query.count()
-
-        self.queryset = query
-
-    def filter_not_in_project(self, count_only=False):
-        query = self.queryset.exclude(layer_version__in=self.project_layers)
-        if count_only:
-            return query.count()
-
-        self.queryset = query
+class ProjectFilters(object):
+    def __init__(self, project_layers):
+        self.in_project = QuerysetFilter(Q(layer_version__in=project_layers))
+        self.not_in_project = QuerysetFilter(~Q(layer_version__in=project_layers))
 
 class LayersTable(ToasterTable):
     """Table of layers in Toaster"""
@@ -60,34 +49,21 @@ class LayersTable(ToasterTable):
 
         return context
 
-
     def setup_filters(self, *args, **kwargs):
         project = Project.objects.get(pk=kwargs['pid'])
         self.project_layers = ProjectLayer.objects.filter(project=project)
 
+        criteria = Q(projectlayer__in=self.project_layers)
+        in_project_filter = QuerysetFilter(criteria)
+        not_in_project_filter = QuerysetFilter(~criteria)
 
         self.add_filter(title="Filter by project layers",
                         name="in_current_project",
                         filter_actions=[
-                            self.make_filter_action("in_project", "Layers added to this project", self.filter_in_project),
-                            self.make_filter_action("not_in_project", "Layers not added to this project", self.filter_not_in_project)
+                            self.make_filter_action("in_project", "Layers added to this project", in_project_filter),
+                            self.make_filter_action("not_in_project", "Layers not added to this project", not_in_project_filter)
                         ])
 
-    def filter_in_project(self, count_only=False):
-        query = self.queryset.filter(projectlayer__in=self.project_layers)
-        if count_only:
-            return query.count()
-
-        self.queryset = query
-
-    def filter_not_in_project(self, count_only=False):
-        query = self.queryset.exclude(projectlayer__in=self.project_layers)
-        if count_only:
-            return query.count()
-
-        self.queryset = query
-
-
     def setup_queryset(self, *args, **kwargs):
         prj = Project.objects.get(pk = kwargs['pid'])
         compatible_layers = prj.get_all_compatible_layer_versions()
@@ -204,7 +180,7 @@ class LayersTable(ToasterTable):
                         computation = lambda x: x.layer.name)
 
 
-class MachinesTable(ToasterTable, ProjectFiltersMixin):
+class MachinesTable(ToasterTable):
     """Table of Machines in Toaster"""
 
     def __init__(self, *args, **kwargs):
@@ -221,11 +197,13 @@ class MachinesTable(ToasterTable, ProjectFiltersMixin):
     def setup_filters(self, *args, **kwargs):
         project = Project.objects.get(pk=kwargs['pid'])
 
+        project_filters = ProjectFilters(self.project_layers)
+
         self.add_filter(title="Filter by project machines",
                         name="in_current_project",
                         filter_actions=[
-                            self.make_filter_action("in_project", "Machines provided by layers added to this project", self.filter_in_project),
-                            self.make_filter_action("not_in_project", "Machines provided by layers not added to this project", self.filter_not_in_project)
+                            self.make_filter_action("in_project", "Machines provided by layers added to this project", project_filters.in_project),
+                            self.make_filter_action("not_in_project", "Machines provided by layers not added to this project", project_filters.not_in_project)
                         ])
 
     def setup_queryset(self, *args, **kwargs):
@@ -313,7 +291,7 @@ class LayerMachinesTable(MachinesTable):
                         static_data_template=select_btn_template)
 
 
-class RecipesTable(ToasterTable, ProjectFiltersMixin):
+class RecipesTable(ToasterTable):
     """Table of All Recipes in Toaster"""
 
     def __init__(self, *args, **kwargs):
@@ -338,11 +316,13 @@ class RecipesTable(ToasterTable, ProjectFiltersMixin):
         return context
 
     def setup_filters(self, *args, **kwargs):
+        project_filters = ProjectFilters(self.project_layers)
+
         self.add_filter(title="Filter by project recipes",
                         name="in_current_project",
                         filter_actions=[
-                            self.make_filter_action("in_project", "Recipes provided by layers added to this project", self.filter_in_project),
-                            self.make_filter_action("not_in_project", "Recipes provided by layers not added to this project", self.filter_not_in_project)
+                            self.make_filter_action("in_project", "Recipes provided by layers added to this project", project_filters.in_project),
+                            self.make_filter_action("not_in_project", "Recipes provided by layers not added to this project", project_filters.not_in_project)
                         ])
 
     def setup_queryset(self, *args, **kwargs):
@@ -1090,52 +1070,20 @@ class BuildsTable(ToasterTable):
                         static_data_name='project-name',
                         static_data_template=project_template)
 
-    def filter_only_failed_builds(self, count_only=False):
-        """ Only show builds with failed outcome """
-        query = self.queryset.filter(outcome=Build.FAILED)
-        if count_only:
-            return query.count()
-
-        self.queryset = query
-
-    def filter_only_successful_builds(self, count_only=False):
-        """ Only show builds with successful outcome """
-        query = self.queryset.filter(outcome=Build.SUCCEEDED)
-        if count_only:
-            return query.count()
-
-        self.queryset = query
-
-    def filter_only_builds_with_failed_tasks(self, count_only=False):
-        """ Only show builds with failed tasks """
-        query = self.queryset.filter(task_build__outcome=Task.OUTCOME_FAILED)
-
-        if count_only:
-            return query.count()
-
-        self.queryset = query
-
-    def filter_only_builds_without_failed_tasks(self, count_only=False):
-        """ Only show builds without failed tasks """
-        query = self.queryset.filter(~Q(task_build__outcome=Task.OUTCOME_FAILED))
-
-        if count_only:
-            return query.count()
-
-        self.queryset = query
-
     def setup_filters(self, *args, **kwargs):
         # outcomes
+        filter_only_successful_builds = QuerysetFilter(Q(outcome=Build.SUCCEEDED))
         successful_builds_filter = self.make_filter_action(
             'successful_builds',
             'Successful builds',
-            self.filter_only_successful_builds
+            filter_only_successful_builds
         )
 
+        filter_only_failed_builds = QuerysetFilter(Q(outcome=Build.FAILED))
         failed_builds_filter = self.make_filter_action(
             'failed_builds',
             'Failed builds',
-            self.filter_only_failed_builds
+            filter_only_failed_builds
         )
 
         self.add_filter(title='Filter builds by outcome',
@@ -1146,16 +1094,20 @@ class BuildsTable(ToasterTable):
                         ])
 
         # failed tasks
+        criteria = Q(task_build__outcome=Task.OUTCOME_FAILED)
+        filter_only_builds_with_failed_tasks = QuerysetFilter(criteria)
         with_failed_tasks_filter = self.make_filter_action(
             'with_failed_tasks',
             'Builds with failed tasks',
-            self.filter_only_builds_with_failed_tasks
+            filter_only_builds_with_failed_tasks
         )
 
+        criteria = ~Q(task_build__outcome=Task.OUTCOME_FAILED)
+        filter_only_builds_without_failed_tasks = QuerysetFilter(criteria)
         without_failed_tasks_filter = self.make_filter_action(
             'without_failed_tasks',
             'Builds without failed tasks',
-            self.filter_only_builds_without_failed_tasks
+            filter_only_builds_without_failed_tasks
         )
 
         self.add_filter(title='Filter builds by failed tasks',
diff --git a/bitbake/lib/toaster/toastergui/widgets.py b/bitbake/lib/toaster/toastergui/widgets.py
index 6bb3889..71b29ea 100644
--- a/bitbake/lib/toaster/toastergui/widgets.py
+++ b/bitbake/lib/toaster/toastergui/widgets.py
@@ -32,6 +32,7 @@ from django.template import Context, Template
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.exceptions import FieldError
 from django.conf.urls import url, patterns
+from toastergui.querysetfilter import QuerysetFilter
 
 import types
 import json
@@ -113,7 +114,8 @@ class ToasterTable(TemplateView):
                               cls=DjangoJSONEncoder)
         else:
             for actions in self.filters[name]['filter_actions']:
-                actions['count'] = self.filter_actions[actions['name']](count_only=True)
+                queryset_filter = self.filter_actions[actions['name']]
+                actions['count'] = queryset_filter.count(self.queryset)
 
             # Add the "All" items filter action
             self.filters[name]['filter_actions'].insert(0, {
@@ -151,15 +153,18 @@ class ToasterTable(TemplateView):
           'filter_actions' : filter_actions,
         }
 
-    def make_filter_action(self, name, title, action_function):
-        """ Utility to make a filter_action """
+    def make_filter_action(self, name, title, queryset_filter):
+        """
+        Utility to make a filter_action; queryset_filter is an instance
+        of QuerysetFilter or a function
+        """
 
         action = {
           'title' : title,
           'name' : name,
         }
 
-        self.filter_actions[name] = action_function
+        self.filter_actions[name] = queryset_filter
 
         return action
 
@@ -222,7 +227,8 @@ class ToasterTable(TemplateView):
             return
 
         try:
-            self.filter_actions[filter_action]()
+            queryset_filter = self.filter_actions[filter_action]
+            self.queryset = queryset_filter.filter(self.queryset)
         except KeyError:
             # pass it to the user - programming error here
             raise
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 08/17] toaster: toastergui Refactor ToasterTable filtering
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (6 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 07/17] toaster: toastergui Add QuerysetFilter class Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 09/17] toaster: toastergui Switch off filter highlights when inactive Elliot Smith
                   ` (9 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

The filter code for ToasterTable was difficult to follow
and inflexible (not allowing different types of filter, for example).

Refactor to a set of filter classes to make the structure cleaner
and provide the flexibility needed for other filter types
(e.g. date range filter).

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/querysetfilter.py  |   7 +-
 bitbake/lib/toaster/toastergui/static/js/table.js |  80 +++++++++----
 bitbake/lib/toaster/toastergui/tablefilter.py     |  98 ++++++++++++++++
 bitbake/lib/toaster/toastergui/tables.py          | 132 ++++++++++++++--------
 bitbake/lib/toaster/toastergui/widgets.py         |  90 +++++++--------
 5 files changed, 289 insertions(+), 118 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/tablefilter.py

diff --git a/bitbake/lib/toaster/toastergui/querysetfilter.py b/bitbake/lib/toaster/toastergui/querysetfilter.py
index 62297e9..dbae239 100644
--- a/bitbake/lib/toaster/toastergui/querysetfilter.py
+++ b/bitbake/lib/toaster/toastergui/querysetfilter.py
@@ -5,7 +5,7 @@ class QuerysetFilter(object):
         if criteria:
             self.set_criteria(criteria)
 
-    def set_criteria(self, criteria):
+    def set_criteria(self, criteria = None):
         """
         criteria is an instance of django.db.models.Q;
         see https://docs.djangoproject.com/en/1.9/ref/models/querysets/#q-objects
@@ -17,7 +17,10 @@ class QuerysetFilter(object):
         Filter queryset according to the criteria for this filter,
         returning the filtered queryset
         """
-        return queryset.filter(self.criteria)
+        if self.criteria:
+            return queryset.filter(self.criteria)
+        else:
+            return queryset
 
     def count(self, queryset):
         """ Returns a count of the elements in the filtered queryset """
diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js
index c69c205..fa01ddf 100644
--- a/bitbake/lib/toaster/toastergui/static/js/table.js
+++ b/bitbake/lib/toaster/toastergui/static/js/table.js
@@ -415,38 +415,76 @@ function tableInit(ctx){
         data: params,
         headers: { 'X-CSRFToken' : $.cookie('csrftoken')},
         success: function (filterData) {
-          var filterActionRadios = $('#filter-actions-'+ctx.tableName);
+          /*
+            filterData structure:
+
+            {
+              title: '<title for the filter popup>',
+              filter_actions: [
+                {
+                  title: '<label for radio button inside the popup>',
+                  name: '<name of the filter action>',
+                  count: <number of items this filter will show>
+                }
+              ]
+            }
 
-          $('#filter-modal-title-'+ctx.tableName).text(filterData.title);
+            each filter_action gets a radio button; the value of this is
+            set to filterName + ':' + filter_action.name; e.g.
 
-          filterActionRadios.text("");
+              in_current_project:in_project
 
-          for (var i in filterData.filter_actions){
-            var filterAction = filterData.filter_actions[i];
+            specifies the "in_project" action of the "in_current_project"
+            filter
 
-            var action = $('<label class="radio"><input type="radio" name="filter" value=""><span class="filter-title"></span></label>');
-            var actionTitle = filterAction.title + ' (' + filterAction.count + ')';
+            the filterName is set on the column filter icon, and corresponds
+            to a value in the table's filters property
 
-            var radioInput = action.children("input");
+            when the filter popup's "Apply" button is clicked, the
+            value for the radio button which is checked is passed in the
+            querystring and applied to the queryset on the table
+           */
 
-            if (Number(filterAction.count) == 0){
-              radioInput.attr("disabled", "disabled");
-            }
+          var filterActionRadios = $('#filter-actions-'+ctx.tableName);
 
-            action.children(".filter-title").text(actionTitle);
+          $('#filter-modal-title-'+ctx.tableName).text(filterData.title);
 
-            radioInput.val(filterName + ':' + filterAction.name);
+          filterActionRadios.text("");
 
-            /* Setup the current selected filter, default to 'all' if
-             * no current filter selected.
-             */
-            if ((tableParams.filter &&
-                tableParams.filter === radioInput.val()) ||
-                filterAction.name == 'all') {
-                radioInput.attr("checked", "checked");
+          for (var i in filterData.filter_actions) {
+            var filterAction = filterData.filter_actions[i];
+            var action = null;
+
+            if (filterAction.type === 'toggle') {
+              var actionTitle = filterAction.title + ' (' + filterAction.count + ')';
+
+              action = $('<label class="radio">' +
+                         '<input type="radio" name="filter" value="">' +
+                         '<span class="filter-title">' +
+                         actionTitle +
+                         '</span>' +
+                         '</label>');
+
+              var radioInput = action.children("input");
+              if (Number(filterAction.count) == 0) {
+                radioInput.attr("disabled", "disabled");
+              }
+
+              radioInput.val(filterData.name + ':' + filterAction.action_name);
+
+              /* Setup the current selected filter, default to 'all' if
+               * no current filter selected.
+               */
+              if ((tableParams.filter &&
+                  tableParams.filter === radioInput.val()) ||
+                  filterAction.action_name == 'all') {
+                  radioInput.attr("checked", "checked");
+              }
             }
 
-            filterActionRadios.append(action);
+            if (action) {
+              filterActionRadios.append(action);
+            }
           }
 
           $('#filter-modal-'+ctx.tableName).modal('show');
diff --git a/bitbake/lib/toaster/toastergui/tablefilter.py b/bitbake/lib/toaster/toastergui/tablefilter.py
new file mode 100644
index 0000000..f1de94a
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/tablefilter.py
@@ -0,0 +1,98 @@
+class TableFilter(object):
+    """
+    Stores a filter for a named field, and can retrieve the action
+    requested for that filter
+    """
+    def __init__(self, name, title):
+        self.name = name
+        self.title = title
+        self.__filter_action_map = {}
+
+    def add_action(self, action):
+        self.__filter_action_map[action.name] = action
+
+    def get_action(self, action_name):
+        return self.__filter_action_map[action_name]
+
+    def to_json(self, queryset):
+        """
+        Dump all filter actions as an object which can be JSON serialised;
+        this is used to generate the JSON for processing in
+        table.js / filterOpenClicked()
+        """
+        filter_actions = []
+
+        # add the "all" pseudo-filter action, which just selects the whole
+        # queryset
+        filter_actions.append({
+            'action_name' : 'all',
+            'title' : 'All',
+            'type': 'toggle',
+            'count' : queryset.count()
+        })
+
+        # add other filter actions
+        for action_name, filter_action in self.__filter_action_map.iteritems():
+            obj = filter_action.to_json(queryset)
+            obj['action_name'] = action_name
+            filter_actions.append(obj)
+
+        return {
+            'name': self.name,
+            'title': self.title,
+            'filter_actions': filter_actions
+        }
+
+class TableFilterActionToggle(object):
+    """
+    Stores a single filter action which will populate one radio button of
+    a ToasterTable filter popup; this filter can either be on or off and
+    has no other parameters
+    """
+
+    def __init__(self, name, title, queryset_filter):
+        self.name = name
+        self.title = title
+        self.__queryset_filter = queryset_filter
+        self.type = 'toggle'
+
+    def set_params(self, params):
+        """
+        params: (str) a string of extra parameters for the action;
+        the structure of this string depends on the type of action;
+        it's ignored for a toggle filter action, which is just on or off
+        """
+        pass
+
+    def filter(self, queryset):
+        return self.__queryset_filter.filter(queryset)
+
+    def to_json(self, queryset):
+        """ Dump as a JSON object """
+        return {
+            'title': self.title,
+            'type': self.type,
+            'count': self.__queryset_filter.count(queryset)
+        }
+
+class TableFilterMap(object):
+    """
+    Map from field names to Filter objects for those fields
+    """
+    def __init__(self):
+        self.__filters = {}
+
+    def add_filter(self, filter_name, table_filter):
+        """ table_filter is an instance of Filter """
+        self.__filters[filter_name] = table_filter
+
+    def get_filter(self, filter_name):
+        return self.__filters[filter_name]
+
+    def to_json(self, queryset):
+        data = {}
+
+        for filter_name, table_filter in self.__filters.iteritems():
+            data[filter_name] = table_filter.to_json()
+
+        return data
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index a49e45c..61ea9cb 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -28,6 +28,8 @@ from django.conf.urls import url
 from django.core.urlresolvers import reverse
 from django.views.generic import TemplateView
 
+from toastergui.tablefilter import TableFilter, TableFilterActionToggle
+
 class ProjectFilters(object):
     def __init__(self, project_layers):
         self.in_project = QuerysetFilter(Q(layer_version__in=project_layers))
@@ -53,16 +55,28 @@ class LayersTable(ToasterTable):
         project = Project.objects.get(pk=kwargs['pid'])
         self.project_layers = ProjectLayer.objects.filter(project=project)
 
+        in_current_project_filter = TableFilter(
+            "in_current_project",
+            "Filter by project layers"
+        )
+
         criteria = Q(projectlayer__in=self.project_layers)
-        in_project_filter = QuerysetFilter(criteria)
-        not_in_project_filter = QuerysetFilter(~criteria)
 
-        self.add_filter(title="Filter by project layers",
-                        name="in_current_project",
-                        filter_actions=[
-                            self.make_filter_action("in_project", "Layers added to this project", in_project_filter),
-                            self.make_filter_action("not_in_project", "Layers not added to this project", not_in_project_filter)
-                        ])
+        in_project_filter_action = TableFilterActionToggle(
+            "in_project",
+            "Layers added to this project",
+            QuerysetFilter(criteria)
+        )
+
+        not_in_project_filter_action = TableFilterActionToggle(
+            "not_in_project",
+            "Layers not added to this project",
+            QuerysetFilter(~criteria)
+        )
+
+        in_current_project_filter.add_action(in_project_filter_action)
+        in_current_project_filter.add_action(not_in_project_filter_action)
+        self.add_filter(in_current_project_filter)
 
     def setup_queryset(self, *args, **kwargs):
         prj = Project.objects.get(pk = kwargs['pid'])
@@ -199,12 +213,26 @@ class MachinesTable(ToasterTable):
 
         project_filters = ProjectFilters(self.project_layers)
 
-        self.add_filter(title="Filter by project machines",
-                        name="in_current_project",
-                        filter_actions=[
-                            self.make_filter_action("in_project", "Machines provided by layers added to this project", project_filters.in_project),
-                            self.make_filter_action("not_in_project", "Machines provided by layers not added to this project", project_filters.not_in_project)
-                        ])
+        in_current_project_filter = TableFilter(
+            "in_current_project",
+            "Filter by project machines"
+        )
+
+        in_project_filter_action = TableFilterActionToggle(
+            "in_project",
+            "Machines provided by layers added to this project",
+            project_filters.in_project
+        )
+
+        not_in_project_filter_action = TableFilterActionToggle(
+            "not_in_project",
+            "Machines provided by layers not added to this project",
+            project_filters.not_in_project
+        )
+
+        in_current_project_filter.add_action(in_project_filter_action)
+        in_current_project_filter.add_action(not_in_project_filter_action)
+        self.add_filter(in_current_project_filter)
 
     def setup_queryset(self, *args, **kwargs):
         prj = Project.objects.get(pk = kwargs['pid'])
@@ -318,12 +346,26 @@ class RecipesTable(ToasterTable):
     def setup_filters(self, *args, **kwargs):
         project_filters = ProjectFilters(self.project_layers)
 
-        self.add_filter(title="Filter by project recipes",
-                        name="in_current_project",
-                        filter_actions=[
-                            self.make_filter_action("in_project", "Recipes provided by layers added to this project", project_filters.in_project),
-                            self.make_filter_action("not_in_project", "Recipes provided by layers not added to this project", project_filters.not_in_project)
-                        ])
+        table_filter = TableFilter(
+            'in_current_project',
+            'Filter by project recipes'
+        )
+
+        in_project_filter_action = TableFilterActionToggle(
+            'in_project',
+            'Recipes provided by layers added to this project',
+            project_filters.in_project
+        )
+
+        not_in_project_filter_action = TableFilterActionToggle(
+            'not_in_project',
+            'Recipes provided by layers not added to this project',
+            project_filters.not_in_project
+        )
+
+        table_filter.add_action(in_project_filter_action)
+        table_filter.add_action(not_in_project_filter_action)
+        self.add_filter(table_filter)
 
     def setup_queryset(self, *args, **kwargs):
         prj = Project.objects.get(pk = kwargs['pid'])
@@ -1072,47 +1114,47 @@ class BuildsTable(ToasterTable):
 
     def setup_filters(self, *args, **kwargs):
         # outcomes
-        filter_only_successful_builds = QuerysetFilter(Q(outcome=Build.SUCCEEDED))
-        successful_builds_filter = self.make_filter_action(
+        outcome_filter = TableFilter(
+            'outcome_filter',
+            'Filter builds by outcome'
+        )
+
+        successful_builds_filter_action = TableFilterActionToggle(
             'successful_builds',
             'Successful builds',
-            filter_only_successful_builds
+            QuerysetFilter(Q(outcome=Build.SUCCEEDED))
         )
 
-        filter_only_failed_builds = QuerysetFilter(Q(outcome=Build.FAILED))
-        failed_builds_filter = self.make_filter_action(
+        failed_builds_filter_action = TableFilterActionToggle(
             'failed_builds',
             'Failed builds',
-            filter_only_failed_builds
+            QuerysetFilter(Q(outcome=Build.FAILED))
         )
 
-        self.add_filter(title='Filter builds by outcome',
-                        name='outcome_filter',
-                        filter_actions = [
-                            successful_builds_filter,
-                            failed_builds_filter
-                        ])
+        outcome_filter.add_action(successful_builds_filter_action)
+        outcome_filter.add_action(failed_builds_filter_action)
+        self.add_filter(outcome_filter)
 
         # failed tasks
+        failed_tasks_filter = TableFilter(
+            'failed_tasks_filter',
+            'Filter builds by failed tasks'
+        )
+
         criteria = Q(task_build__outcome=Task.OUTCOME_FAILED)
-        filter_only_builds_with_failed_tasks = QuerysetFilter(criteria)
-        with_failed_tasks_filter = self.make_filter_action(
+
+        with_failed_tasks_filter_action = TableFilterActionToggle(
             'with_failed_tasks',
             'Builds with failed tasks',
-            filter_only_builds_with_failed_tasks
+            QuerysetFilter(criteria)
         )
 
-        criteria = ~Q(task_build__outcome=Task.OUTCOME_FAILED)
-        filter_only_builds_without_failed_tasks = QuerysetFilter(criteria)
-        without_failed_tasks_filter = self.make_filter_action(
+        without_failed_tasks_filter_action = TableFilterActionToggle(
             'without_failed_tasks',
             'Builds without failed tasks',
-            filter_only_builds_without_failed_tasks
+            QuerysetFilter(~criteria)
         )
 
-        self.add_filter(title='Filter builds by failed tasks',
-                        name='failed_tasks_filter',
-                        filter_actions = [
-                            with_failed_tasks_filter,
-                            without_failed_tasks_filter
-                        ])
+        failed_tasks_filter.add_action(with_failed_tasks_filter_action)
+        failed_tasks_filter.add_action(without_failed_tasks_filter_action)
+        self.add_filter(failed_tasks_filter)
diff --git a/bitbake/lib/toaster/toastergui/widgets.py b/bitbake/lib/toaster/toastergui/widgets.py
index 71b29ea..8790340 100644
--- a/bitbake/lib/toaster/toastergui/widgets.py
+++ b/bitbake/lib/toaster/toastergui/widgets.py
@@ -39,11 +39,13 @@ import json
 import collections
 import operator
 import re
+import urllib
 
 import logging
 logger = logging.getLogger("toaster")
 
 from toastergui.views import objtojson
+from toastergui.tablefilter import TableFilterMap
 
 class ToasterTable(TemplateView):
     def __init__(self, *args, **kwargs):
@@ -53,7 +55,10 @@ class ToasterTable(TemplateView):
         self.title = "Table"
         self.queryset = None
         self.columns = []
-        self.filters = {}
+
+        # map from field names to Filter instances
+        self.filter_map = TableFilterMap()
+
         self.total_count = 0
         self.static_context_extra = {}
         self.filter_actions = {}
@@ -66,7 +71,7 @@ class ToasterTable(TemplateView):
                         orderable=True,
                         field_name="id")
 
-        # prevent HTTP caching of table data
+    # prevent HTTP caching of table data
     @cache_control(must_revalidate=True, max_age=0, no_store=True, no_cache=True)
     def dispatch(self, *args, **kwargs):
         return super(ToasterTable, self).dispatch(*args, **kwargs)
@@ -108,27 +113,10 @@ class ToasterTable(TemplateView):
             self.apply_search(search)
 
         name = request.GET.get("name", None)
-        if name is None:
-            data = json.dumps(self.filters,
-                              indent=2,
-                              cls=DjangoJSONEncoder)
-        else:
-            for actions in self.filters[name]['filter_actions']:
-                queryset_filter = self.filter_actions[actions['name']]
-                actions['count'] = queryset_filter.count(self.queryset)
-
-            # Add the "All" items filter action
-            self.filters[name]['filter_actions'].insert(0, {
-                'name' : 'all',
-                'title' : 'All',
-                'count' : self.queryset.count(),
-            })
-
-            data = json.dumps(self.filters[name],
-                              indent=2,
-                              cls=DjangoJSONEncoder)
-
-            return data
+        table_filter = self.filter_map.get_filter(name)
+        return json.dumps(table_filter.to_json(self.queryset),
+                          indent=2,
+                          cls=DjangoJSONEncoder)
 
     def setup_columns(self, *args, **kwargs):
         """ function to implement in the subclass which sets up the columns """
@@ -140,33 +128,13 @@ class ToasterTable(TemplateView):
         """ function to implement in the subclass which sets up the queryset"""
         pass
 
-    def add_filter(self, name, title, filter_actions):
+    def add_filter(self, table_filter):
         """Add a filter to the table.
 
         Args:
-            name (str): Unique identifier of the filter.
-            title (str): Title of the filter.
-            filter_actions: Actions for all the filters.
+            table_filter: Filter instance
         """
-        self.filters[name] = {
-          'title' : title,
-          'filter_actions' : filter_actions,
-        }
-
-    def make_filter_action(self, name, title, queryset_filter):
-        """
-        Utility to make a filter_action; queryset_filter is an instance
-        of QuerysetFilter or a function
-        """
-
-        action = {
-          'title' : title,
-          'name' : name,
-        }
-
-        self.filter_actions[name] = queryset_filter
-
-        return action
+        self.filter_map.add_filter(table_filter.name, table_filter)
 
     def add_column(self, title="", help_text="",
                    orderable=False, hideable=True, hidden=False,
@@ -216,19 +184,41 @@ class ToasterTable(TemplateView):
         return template.render(context)
 
     def apply_filter(self, filters, **kwargs):
+        """
+        Apply a filter submitted in the querystring to the ToasterTable
+
+        filters: (str) in the format:
+          '<filter name>:<action name>!<action params>'
+        where <action params> is optional
+
+        <filter name> and <action name> are used to look up the correct filter
+        in the ToasterTable's filter map; the <action params> are set on
+        TableFilterAction* before its filter is applied and may modify the
+        queryset returned by the filter
+        """
         self.setup_filters(**kwargs)
 
         try:
-            filter_name, filter_action = filters.split(':')
+            filter_name, action_name_and_params = filters.split(':')
+
+            action_name = None
+            action_params = None
+            if re.search('!', action_name_and_params):
+                action_name, action_params = action_name_and_params.split('!')
+                action_params = urllib.unquote_plus(action_params)
+            else:
+                action_name = action_name_and_params
         except ValueError:
             return
 
-        if "all" in filter_action:
+        if "all" in action_name:
             return
 
         try:
-            queryset_filter = self.filter_actions[filter_action]
-            self.queryset = queryset_filter.filter(self.queryset)
+            table_filter = self.filter_map.get_filter(filter_name)
+            action = table_filter.get_action(action_name)
+            action.set_params(action_params)
+            self.queryset = action.filter(self.queryset)
         except KeyError:
             # pass it to the user - programming error here
             raise
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 09/17] toaster: toastergui Switch off filter highlights when inactive
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (7 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 08/17] toaster: toastergui Refactor ToasterTable filtering Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 10/17] toaster: toastergui Show recent builds on all builds page Elliot Smith
                   ` (8 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

In ToasterTables with multiple columns which allow filtering
(e.g. all builds), selecting one filter, then a second filter
(e.g. selecting "failed builds" then "outcome" for all builds),
would result in both filters being highlighted at the same time.

Fix this by removing the "active" highlight on all column filter
buttons when a new filter value is submitted (via the filter modal).

NB to enable this, added a data-filter-on attribute to all
column filter buttons to make them easy to select.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/static/js/table.js | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js
index fa01ddf..63f8a1f 100644
--- a/bitbake/lib/toaster/toastergui/static/js/table.js
+++ b/bitbake/lib/toaster/toastergui/static/js/table.js
@@ -248,7 +248,7 @@ function tableInit(ctx){
 
       /* Setup the filter button */
       if (col.filter_name){
-        var filterBtn = $('<a href="#" role="button" class="pull-right btn btn-mini" data-toggle="modal"><i class="icon-filter filtered"></i></a>');
+        var filterBtn = $('<a href="#" role="button" data-filter-on="' + col.filter_name + '" class="pull-right btn btn-mini" data-toggle="modal"><i class="icon-filter filtered"></i></a>');
 
         filterBtn.data('filter-name', col.filter_name);
         filterBtn.prop('id', col.filter_name);
@@ -565,6 +565,12 @@ function tableInit(ctx){
   $("#filter-modal-form-"+ctx.tableName).submit(function(e){
     e.preventDefault();
 
+    /* remove active status from all filter buttons so that only one filter
+       can be active at a time */
+    $('[data-filter-on]').each(function (index, filterBtn) {
+      filterBtnActive($(filterBtn), false);
+    });
+
     tableParams.filter = $(this).find("input[type='radio']:checked").val();
 
     var filterBtn = $("#" + tableParams.filter.split(":")[0]);
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 10/17] toaster: toastergui Show recent builds on all builds page
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (8 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 09/17] toaster: toastergui Switch off filter highlights when inactive Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 11/17] toaster: toastergui Implement date range filters for builds Elliot Smith
                   ` (7 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

The recent builds section was disabled while converting the
all builds page to ToasterTable.

Re-enable the recent builds area and add the data it requires
to the ToasterTable context.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/tables.py               | 18 +++++++++++++++++-
 .../toastergui/templates/builds-toastertable.html      | 10 +++++-----
 .../lib/toaster/toastergui/templates/mrb_section.html  |  2 +-
 3 files changed, 23 insertions(+), 7 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index 61ea9cb..22a5a6a 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -27,6 +27,7 @@ from django.db.models import Q, Max, Count
 from django.conf.urls import url
 from django.core.urlresolvers import reverse
 from django.views.generic import TemplateView
+import itertools
 
 from toastergui.tablefilter import TableFilter, TableFilterActionToggle
 
@@ -889,7 +890,22 @@ class BuildsTable(ToasterTable):
         self.static_context_extra['Task'] = Task
 
     def get_context_data(self, **kwargs):
-        return super(BuildsTable, self).get_context_data(**kwargs)
+        context = super(BuildsTable, self).get_context_data(**kwargs)
+
+        # for the latest builds section
+        queryset = Build.objects.all()
+
+        finished_criteria = Q(outcome=Build.SUCCEEDED) | Q(outcome=Build.FAILED)
+
+        latest_builds = itertools.chain(
+            queryset.filter(outcome=Build.IN_PROGRESS).order_by("-started_on"),
+            queryset.filter(finished_criteria).order_by("-completed_on")[:3]
+        )
+
+        context['mru'] = list(latest_builds)
+        context['mrb_type'] = 'all'
+
+        return context
 
     def setup_queryset(self, *args, **kwargs):
         queryset = Build.objects.all()
diff --git a/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
index 419d2b5..f7604fd 100644
--- a/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
+++ b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
@@ -3,13 +3,13 @@
 {% block title %} All builds - Toaster {% endblock %}
 
 {% block pagecontent %}
-  <div class="page-header top-air">
-    <h1 data-role="page-title"></h1>
-  </div>
 
   <div class="row-fluid">
-    {# TODO need to pass this data to context #}
-    {#% include 'mrb_section.html' %#}
+    {% with mru=mru mrb_type=mrb_type %}
+      {% include 'mrb_section.html' %}
+    {% endwith %}
+
+    <h1  class="page-header top-air" data-role="page-title"></h1>
 
     {% url 'builds' as xhr_table_url %}
     {% include 'toastertable.html' %}
diff --git a/bitbake/lib/toaster/toastergui/templates/mrb_section.html b/bitbake/lib/toaster/toastergui/templates/mrb_section.html
index bd8f991..52b3f1a 100644
--- a/bitbake/lib/toaster/toastergui/templates/mrb_section.html
+++ b/bitbake/lib/toaster/toastergui/templates/mrb_section.html
@@ -14,7 +14,7 @@
       {% endif %}
       </h2>
   {% else %}
-    <div class="page-header">
+    <div class="page-header top-air">
       <h1>
       Latest builds
       </h1>
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 11/17] toaster: toastergui Implement date range filters for builds
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (9 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 10/17] toaster: toastergui Show recent builds on all builds page Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 12/17] toaster: toastergui Implement "today" and "yesterday" filters Elliot Smith
                   ` (6 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Implement the completed_on and started_on filtering for
builds.

Also separate the name of a filter ("filter" in the querystring)
from its value ("filter_value" in the querystring). This enables
filtering to be defined in the querystring more intuitively,
and also makes it easier to add other types of filter (e.g.
by day).

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/querysetfilter.py   |   3 +-
 bitbake/lib/toaster/toastergui/static/js/table.js  | 192 +++++++++++++++++----
 bitbake/lib/toaster/toastergui/tablefilter.py      | 114 ++++++++++--
 bitbake/lib/toaster/toastergui/tables.py           |  38 +++-
 .../toastergui/templates/builds-toastertable.html  |  32 +---
 bitbake/lib/toaster/toastergui/widgets.py          |  32 ++--
 6 files changed, 329 insertions(+), 82 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/querysetfilter.py b/bitbake/lib/toaster/toastergui/querysetfilter.py
index dbae239..efa8507 100644
--- a/bitbake/lib/toaster/toastergui/querysetfilter.py
+++ b/bitbake/lib/toaster/toastergui/querysetfilter.py
@@ -2,10 +2,11 @@ class QuerysetFilter(object):
     """ Filter for a queryset """
 
     def __init__(self, criteria=None):
+        self.criteria = None
         if criteria:
             self.set_criteria(criteria)
 
-    def set_criteria(self, criteria = None):
+    def set_criteria(self, criteria):
         """
         criteria is an instance of django.db.models.Q;
         see https://docs.djangoproject.com/en/1.9/ref/models/querysets/#q-objects
diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js
index 63f8a1f..c619956 100644
--- a/bitbake/lib/toaster/toastergui/static/js/table.js
+++ b/bitbake/lib/toaster/toastergui/static/js/table.js
@@ -397,6 +397,135 @@ function tableInit(ctx){
     $.cookie("cols", JSON.stringify(disabled_cols));
   }
 
+  /**
+   * Create the DOM/JS for the client side of a TableFilterActionToggle
+   *
+   * filterName: (string) internal name for the filter action
+   * filterActionData: (object)
+   * filterActionData.count: (number) The number of items this filter will
+   * show when selected
+   */
+  function createActionToggle(filterName, filterActionData) {
+    var actionStr = '<div class="radio">' +
+                    '<input type="radio" name="filter"' +
+                    '       value="' + filterName + '"';
+
+    if (Number(filterActionData.count) == 0) {
+      actionStr += ' disabled="disabled"';
+    }
+
+    actionStr += ' id="' + filterName + '">' +
+                 '<input type="hidden" name="filter_value" value="on"' +
+                 '       data-value-for="' + filterName + '">' +
+                 '<label class="filter-title"' +
+                 '       for="' + filterName + '">' +
+                 filterActionData.title +
+                 ' (' + filterActionData.count + ')' +
+                 '</label>' +
+                 '</div>';
+
+    return $(actionStr);
+  }
+
+  /**
+   * Create the DOM/JS for the client side of a TableFilterActionDateRange
+   *
+   * filterName: (string) internal name for the filter action
+   * filterValue: (string) from,to date range in format yyyy-mm-dd,yyyy-mm-dd;
+   * used to select the current values for the from/to datepickers;
+   * if this is partial (e.g. "yyyy-mm-dd,") only the applicable datepicker
+   * will have a date pre-selected; if empty, neither will
+   * filterActionData: (object) data for generating the action's HTML
+   * filterActionData.title: label for the radio button
+   * filterActionData.max: (string) maximum date for the pickers, in ISO 8601
+   * datetime format
+   * filterActionData.min: (string) minimum date for the pickers, ISO 8601
+   * datetime
+   */
+  function createActionDateRange(filterName, filterValue, filterActionData) {
+    var action = $('<div class="radio">' +
+                   '<input type="radio" name="filter"' +
+                   '       value="' + filterName + '" ' +
+                   '       id="' + filterName + '">' +
+                   '<input type="hidden" name="filter_value" value=""' +
+                   '       data-value-for="' + filterName + '">' +
+                   '<label class="filter-title"' +
+                   '       for="' + filterName + '">' +
+                   filterActionData.title +
+                   '</label>' +
+                   '<input type="text" maxlength="10" class="input-small"' +
+                   '       data-date-from-for="' + filterName + '">' +
+                   '<span class="help-inline">to</span>' +
+                   '<input type="text" maxlength="10" class="input-small"' +
+                   '       data-date-to-for="' + filterName + '">' +
+                   '<span class="help-inline get-help">(yyyy-mm-dd)</span>' +
+                   '</div>');
+
+    var radio = action.find('[type="radio"]');
+    var value = action.find('[data-value-for]');
+
+    // make the datepickers for the range
+    var options = {
+      dateFormat: 'yy-mm-dd',
+      maxDate: new Date(filterActionData.max),
+      minDate: new Date(filterActionData.min)
+    };
+
+    // create date pickers, setting currently-selected from and to
+    // dates
+    var selectedFrom = null;
+    var selectedTo = null;
+
+    var selectedFromAndTo = [];
+    if (filterValue) {
+      selectedFromAndTo = filterValue.split(',');
+    }
+
+    if (selectedFromAndTo.length == 2) {
+      selectedFrom = selectedFromAndTo[0];
+      selectedTo = selectedFromAndTo[1];
+    }
+
+    options.defaultDate = selectedFrom;
+    var inputFrom =
+      action.find('[data-date-from-for]').datepicker(options);
+    inputFrom.val(selectedFrom);
+
+    options.defaultDate = selectedTo;
+    var inputTo =
+      action.find('[data-date-to-for]').datepicker(options);
+    inputTo.val(selectedTo);
+
+    // set filter_value based on date pickers when
+    // one of their values changes
+    var changeHandler = function () {
+      value.val(inputFrom.val() + ',' + inputTo.val());
+    };
+
+    inputFrom.change(changeHandler);
+    inputTo.change(changeHandler);
+
+    // check the associated radio button on clicking a date picker
+    var checkRadio = function () {
+      radio.prop('checked', 'checked');
+    };
+
+    inputFrom.focus(checkRadio);
+    inputTo.focus(checkRadio);
+
+    // selecting a date in a picker constrains the date you can
+    // set in the other picker
+    inputFrom.change(function () {
+      inputTo.datepicker('option', 'minDate', inputFrom.val());
+    });
+
+    inputTo.change(function () {
+      inputFrom.datepicker('option', 'maxDate', inputTo.val());
+    });
+
+    return action;
+  }
+
   function filterOpenClicked(){
     var filterName = $(this).data('filter-name');
 
@@ -443,46 +572,44 @@ function tableInit(ctx){
             when the filter popup's "Apply" button is clicked, the
             value for the radio button which is checked is passed in the
             querystring and applied to the queryset on the table
-           */
+          */
+          var filterActionRadios = $('#filter-actions-' + ctx.tableName);
 
-          var filterActionRadios = $('#filter-actions-'+ctx.tableName);
+          $('#filter-modal-title-' + ctx.tableName).text(filterData.title);
 
-          $('#filter-modal-title-'+ctx.tableName).text(filterData.title);
-
-          filterActionRadios.text("");
+          filterActionRadios.empty();
 
+          // create a radio button + form elements for each action associated
+          // with the filter on this column of the table
           for (var i in filterData.filter_actions) {
-            var filterAction = filterData.filter_actions[i];
             var action = null;
+            var filterActionData = filterData.filter_actions[i];
+            var filterName = filterData.name + ':' +
+                             filterActionData.action_name;
 
-            if (filterAction.type === 'toggle') {
-              var actionTitle = filterAction.title + ' (' + filterAction.count + ')';
-
-              action = $('<label class="radio">' +
-                         '<input type="radio" name="filter" value="">' +
-                         '<span class="filter-title">' +
-                         actionTitle +
-                         '</span>' +
-                         '</label>');
-
-              var radioInput = action.children("input");
-              if (Number(filterAction.count) == 0) {
-                radioInput.attr("disabled", "disabled");
-              }
-
-              radioInput.val(filterData.name + ':' + filterAction.action_name);
+            if (filterActionData.type === 'toggle') {
+              action = createActionToggle(filterName, filterActionData);
+            }
+            else if (filterActionData.type === 'daterange') {
+              var filterValue = tableParams.filter_value;
+
+              action = createActionDateRange(
+                filterName,
+                filterValue,
+                filterActionData
+              );
+            }
 
-              /* Setup the current selected filter, default to 'all' if
-               * no current filter selected.
-               */
+            if (action) {
+              // Setup the current selected filter, default to 'all' if
+              // no current filter selected
+              var radioInput = action.children('input[name="filter"]');
               if ((tableParams.filter &&
                   tableParams.filter === radioInput.val()) ||
-                  filterAction.action_name == 'all') {
+                  filterActionData.action_name == 'all') {
                   radioInput.attr("checked", "checked");
               }
-            }
 
-            if (action) {
               filterActionRadios.append(action);
             }
           }
@@ -571,7 +698,14 @@ function tableInit(ctx){
       filterBtnActive($(filterBtn), false);
     });
 
-    tableParams.filter = $(this).find("input[type='radio']:checked").val();
+    // checked radio button
+    var checkedFilter = $(this).find("input[name='filter']:checked");
+    tableParams.filter = checkedFilter.val();
+
+    // hidden field holding the value for the checked filter
+    var checkedFilterValue = $(this).find("input[data-value-for='" +
+                                          tableParams.filter + "']");
+    tableParams.filter_value = checkedFilterValue.val();
 
     var filterBtn = $("#" + tableParams.filter.split(":")[0]);
 
diff --git a/bitbake/lib/toaster/toastergui/tablefilter.py b/bitbake/lib/toaster/toastergui/tablefilter.py
index f1de94a..eb053ac 100644
--- a/bitbake/lib/toaster/toastergui/tablefilter.py
+++ b/bitbake/lib/toaster/toastergui/tablefilter.py
@@ -1,8 +1,12 @@
+from django.db.models import Q, Max, Min
+from django.utils import dateparse, timezone
+
 class TableFilter(object):
     """
     Stores a filter for a named field, and can retrieve the action
-    requested for that filter
+    requested from the set of actions for that filter
     """
+
     def __init__(self, name, title):
         self.name = name
         self.title = title
@@ -43,42 +47,128 @@ class TableFilter(object):
             'filter_actions': filter_actions
         }
 
-class TableFilterActionToggle(object):
+class TableFilterAction(object):
     """
-    Stores a single filter action which will populate one radio button of
-    a ToasterTable filter popup; this filter can either be on or off and
-    has no other parameters
+    A filter action which displays in the filter popup for a ToasterTable
+    and uses an associated QuerysetFilter to filter the queryset for that
+    ToasterTable
     """
 
     def __init__(self, name, title, queryset_filter):
         self.name = name
         self.title = title
-        self.__queryset_filter = queryset_filter
-        self.type = 'toggle'
+        self.queryset_filter = queryset_filter
 
-    def set_params(self, params):
+        # set in subclasses
+        self.type = None
+
+    def set_filter_params(self, params):
         """
         params: (str) a string of extra parameters for the action;
         the structure of this string depends on the type of action;
         it's ignored for a toggle filter action, which is just on or off
         """
-        pass
+        if not params:
+            return
 
     def filter(self, queryset):
-        return self.__queryset_filter.filter(queryset)
+        return self.queryset_filter.filter(queryset)
 
     def to_json(self, queryset):
         """ Dump as a JSON object """
         return {
             'title': self.title,
             'type': self.type,
-            'count': self.__queryset_filter.count(queryset)
+            'count': self.queryset_filter.count(queryset)
         }
 
+class TableFilterActionToggle(TableFilterAction):
+    """
+    A single filter action which will populate one radio button of
+    a ToasterTable filter popup; this filter can either be on or off and
+    has no other parameters
+    """
+
+    def __init__(self, *args):
+        super(TableFilterActionToggle, self).__init__(*args)
+        self.type = 'toggle'
+
+class TableFilterActionDateRange(TableFilterAction):
+    """
+    A filter action which will filter the queryset by a date range.
+    The date range can be set via set_params()
+    """
+
+    def __init__(self, name, title, field, queryset_filter):
+        """
+        field: the field to find the max/min range from in the queryset
+        """
+        super(TableFilterActionDateRange, self).__init__(
+            name,
+            title,
+            queryset_filter
+        )
+
+        self.type = 'daterange'
+        self.field = field
+
+    def set_filter_params(self, params):
+        """
+        params: (str) a string of extra parameters for the filtering
+        in the format "2015-12-09,2015-12-11" (from,to); this is passed in the
+        querystring and used to set the criteria on the QuerysetFilter
+        associated with this action
+        """
+
+        # if params are invalid, return immediately, resetting criteria
+        # on the QuerysetFilter
+        try:
+            from_date_str, to_date_str = params.split(',')
+        except ValueError:
+            self.queryset_filter.set_criteria(None)
+            return
+
+        # one of the values required for the filter is missing, so set
+        # it to the one which was supplied
+        if from_date_str == '':
+            from_date_str = to_date_str
+        elif to_date_str == '':
+            to_date_str = from_date_str
+
+        date_from_naive = dateparse.parse_datetime(from_date_str + ' 00:00:00')
+        date_to_naive = dateparse.parse_datetime(to_date_str + ' 23:59:59')
+
+        tz = timezone.get_default_timezone()
+        date_from = timezone.make_aware(date_from_naive, tz)
+        date_to = timezone.make_aware(date_to_naive, tz)
+
+        args = {}
+        args[self.field + '__gte'] = date_from
+        args[self.field + '__lte'] = date_to
+
+        criteria = Q(**args)
+        self.queryset_filter.set_criteria(criteria)
+
+    def to_json(self, queryset):
+        """ Dump as a JSON object """
+        data = super(TableFilterActionDateRange, self).to_json(queryset)
+
+        # additional data about the date range covered by the queryset's
+        # records, retrieved from its <field> column
+        data['min'] = queryset.aggregate(Min(self.field))[self.field + '__min']
+        data['max'] = queryset.aggregate(Max(self.field))[self.field + '__max']
+
+        # a range filter has a count of None, as the number of records it
+        # will select depends on the date range entered
+        data['count'] = None
+
+        return data
+
 class TableFilterMap(object):
     """
-    Map from field names to Filter objects for those fields
+    Map from field names to TableFilter objects for those fields
     """
+
     def __init__(self):
         self.__filters = {}
 
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index 22a5a6a..a9efc0c 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -29,7 +29,9 @@ from django.core.urlresolvers import reverse
 from django.views.generic import TemplateView
 import itertools
 
-from toastergui.tablefilter import TableFilter, TableFilterActionToggle
+from toastergui.tablefilter import TableFilter
+from toastergui.tablefilter import TableFilterActionToggle
+from toastergui.tablefilter import TableFilterActionDateRange
 
 class ProjectFilters(object):
     def __init__(self, project_layers):
@@ -1072,6 +1074,7 @@ class BuildsTable(ToasterTable):
                         help_text='The date and time when the build started',
                         hideable=True,
                         orderable=True,
+                        filter_name='started_on_filter',
                         static_data_name='started_on',
                         static_data_template=started_on_template)
 
@@ -1079,6 +1082,7 @@ class BuildsTable(ToasterTable):
                         help_text='The date and time when the build finished',
                         hideable=False,
                         orderable=True,
+                        filter_name='completed_on_filter',
                         static_data_name='completed_on',
                         static_data_template=completed_on_template)
 
@@ -1151,6 +1155,38 @@ class BuildsTable(ToasterTable):
         outcome_filter.add_action(failed_builds_filter_action)
         self.add_filter(outcome_filter)
 
+        # started on
+        started_on_filter = TableFilter(
+            'started_on_filter',
+            'Filter by date when build was started'
+        )
+
+        by_started_date_range_filter_action = TableFilterActionDateRange(
+            'date_range',
+            'Build date range',
+            'started_on',
+            QuerysetFilter()
+        )
+
+        started_on_filter.add_action(by_started_date_range_filter_action)
+        self.add_filter(started_on_filter)
+
+        # completed on
+        completed_on_filter = TableFilter(
+            'completed_on_filter',
+            'Filter by date when build was completed'
+        )
+
+        by_completed_date_range_filter_action = TableFilterActionDateRange(
+            'date_range',
+            'Build date range',
+            'completed_on',
+            QuerysetFilter()
+        )
+
+        completed_on_filter.add_action(by_completed_date_range_filter_action)
+        self.add_filter(completed_on_filter)
+
         # failed tasks
         failed_tasks_filter = TableFilter(
             'failed_tasks_filter',
diff --git a/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
index f7604fd..2e32edb 100644
--- a/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
+++ b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
@@ -1,4 +1,13 @@
 {% extends 'base.html' %}
+{% load static %}
+
+{% block extraheadcontent %}
+  <link rel="stylesheet" href="{% static 'css/jquery-ui.min.css' %}" type='text/css'>
+  <link rel="stylesheet" href="{% static 'css/jquery-ui.structure.min.css' %}" type='text/css'>
+  <link rel="stylesheet" href="{% static 'css/jquery-ui.theme.min.css' %}" type='text/css'>
+  <script src="{% static 'js/jquery-ui.min.js' %}">
+  </script>
+{% endblock %}
 
 {% block title %} All builds - Toaster {% endblock %}
 
@@ -34,29 +43,6 @@
 
         titleElt.text(title);
       });
-
-      /* {% if last_date_from and last_date_to %}
-      // TODO initialize the date range controls;
-      // this will need to be added via ToasterTable
-      date_init(
-        "started_on",
-        "{{last_date_from}}",
-        "{{last_date_to}}",
-        "{{dateMin_started_on}}",
-        "{{dateMax_started_on}}",
-        "{{daterange_selected}}"
-      );
-
-      date_init(
-        "completed_on",
-        "{{last_date_from}}",
-        "{{last_date_to}}",
-        "{{dateMin_completed_on}}",
-        "{{dateMax_completed_on}}",
-        "{{daterange_selected}}"
-      );
-      {% endif %}
-      */
     });
   </script>
 {% endblock %}
diff --git a/bitbake/lib/toaster/toastergui/widgets.py b/bitbake/lib/toaster/toastergui/widgets.py
index 8790340..47de30d 100644
--- a/bitbake/lib/toaster/toastergui/widgets.py
+++ b/bitbake/lib/toaster/toastergui/widgets.py
@@ -183,13 +183,13 @@ class ToasterTable(TemplateView):
 
         return template.render(context)
 
-    def apply_filter(self, filters, **kwargs):
+    def apply_filter(self, filters, filter_value, **kwargs):
         """
         Apply a filter submitted in the querystring to the ToasterTable
 
         filters: (str) in the format:
-          '<filter name>:<action name>!<action params>'
-        where <action params> is optional
+          '<filter name>:<action name>'
+        filter_value: (str) parameters to pass to the named filter
 
         <filter name> and <action name> are used to look up the correct filter
         in the ToasterTable's filter map; the <action params> are set on
@@ -199,15 +199,8 @@ class ToasterTable(TemplateView):
         self.setup_filters(**kwargs)
 
         try:
-            filter_name, action_name_and_params = filters.split(':')
-
-            action_name = None
-            action_params = None
-            if re.search('!', action_name_and_params):
-                action_name, action_params = action_name_and_params.split('!')
-                action_params = urllib.unquote_plus(action_params)
-            else:
-                action_name = action_name_and_params
+            filter_name, action_name = filters.split(':')
+            action_params = urllib.unquote_plus(filter_value)
         except ValueError:
             return
 
@@ -217,7 +210,7 @@ class ToasterTable(TemplateView):
         try:
             table_filter = self.filter_map.get_filter(filter_name)
             action = table_filter.get_action(action_name)
-            action.set_params(action_params)
+            action.set_filter_params(action_params)
             self.queryset = action.filter(self.queryset)
         except KeyError:
             # pass it to the user - programming error here
@@ -247,13 +240,20 @@ class ToasterTable(TemplateView):
 
 
     def get_data(self, request, **kwargs):
-        """Returns the data for the page requested with the specified
-        parameters applied"""
+        """
+        Returns the data for the page requested with the specified
+        parameters applied
+
+        filters: filter and action name, e.g. "outcome:build_succeeded"
+        filter_value: value to pass to the named filter+action, e.g. "on"
+        (for a toggle filter) or "2015-12-11,2015-12-12" (for a date range filter)
+        """
 
         page_num = request.GET.get("page", 1)
         limit = request.GET.get("limit", 10)
         search = request.GET.get("search", None)
         filters = request.GET.get("filter", None)
+        filter_value = request.GET.get("filter_value", "on")
         orderby = request.GET.get("orderby", None)
         nocache = request.GET.get("nocache", None)
 
@@ -285,7 +285,7 @@ class ToasterTable(TemplateView):
         if search:
             self.apply_search(search)
         if filters:
-            self.apply_filter(filters, **kwargs)
+            self.apply_filter(filters, filter_value, **kwargs)
         if orderby:
             self.apply_orderby(orderby)
 
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 12/17] toaster: toastergui Implement "today" and "yesterday" filters
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (10 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 11/17] toaster: toastergui Implement date range filters for builds Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 13/17] toaster: toastergui Convert project builds page to ToasterTable Elliot Smith
                   ` (5 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Add the "today" and "yesterday" filters to the started_on
and completed_on columns in the builds table.

During this work, some minor adjustments were made to the
behaviour of the builds table:

* Amend filter action variable names so they're more succinct.
* Retain order in which actions are added to a filter, as this
ordering is used in the UI when displaying the filter actions.
* Always show the table chrome, otherwise it's not possible
to edit the columns shown until there are 10 or more results.
* Because date range searches may return no results, make sure
that the search bar and "show all results" link are visible
when the query returns no results.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/querysetfilter.py   |   4 -
 bitbake/lib/toaster/toastergui/static/js/table.js  |  56 +++++----
 bitbake/lib/toaster/toastergui/tablefilter.py      | 139 +++++++++++++++++----
 bitbake/lib/toaster/toastergui/tables.py           |  87 ++++++++-----
 .../toastergui/templates/builds-toastertable.html  |   2 +-
 .../toaster/toastergui/templates/toastertable.html |   7 +-
 6 files changed, 211 insertions(+), 84 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/querysetfilter.py b/bitbake/lib/toaster/toastergui/querysetfilter.py
index efa8507..10cc988 100644
--- a/bitbake/lib/toaster/toastergui/querysetfilter.py
+++ b/bitbake/lib/toaster/toastergui/querysetfilter.py
@@ -22,7 +22,3 @@ class QuerysetFilter(object):
             return queryset.filter(self.criteria)
         else:
             return queryset
-
-    def count(self, queryset):
-        """ Returns a count of the elements in the filtered queryset """
-        return self.filter(queryset).count()
diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js
index c619956..d77ebaf 100644
--- a/bitbake/lib/toaster/toastergui/static/js/table.js
+++ b/bitbake/lib/toaster/toastergui/static/js/table.js
@@ -71,22 +71,11 @@ function tableInit(ctx){
 
     if (tableData.total === 0){
       tableContainer.hide();
-      /* If we were searching show the new search bar and return */
-      if (tableParams.search){
-        $("#new-search-input-"+ctx.tableName).val(tableParams.search);
-        $("#no-results-"+ctx.tableName).show();
-      }
+      $("#new-search-input-"+ctx.tableName).val(tableParams.search);
+      $("#no-results-"+ctx.tableName).show();
       table.trigger("table-done", [tableData.total, tableParams]);
 
       return;
-
-    /* We don't want to clutter the place with the table chrome if there
-     * are only a few results */
-    } else if (tableData.total <= 10 &&
-               !tableParams.filter &&
-               !tableParams.search){
-      $("#table-chrome-"+ctx.tableName).hide();
-      pagination.hide();
     } else {
       tableContainer.show();
       $("#no-results-"+ctx.tableName).hide();
@@ -399,13 +388,14 @@ function tableInit(ctx){
 
   /**
    * Create the DOM/JS for the client side of a TableFilterActionToggle
+   * or TableFilterActionDay
    *
    * filterName: (string) internal name for the filter action
    * filterActionData: (object)
    * filterActionData.count: (number) The number of items this filter will
    * show when selected
    */
-  function createActionToggle(filterName, filterActionData) {
+  function createActionRadio(filterName, filterActionData) {
     var actionStr = '<div class="radio">' +
                     '<input type="radio" name="filter"' +
                     '       value="' + filterName + '"';
@@ -471,8 +461,7 @@ function tableInit(ctx){
       minDate: new Date(filterActionData.min)
     };
 
-    // create date pickers, setting currently-selected from and to
-    // dates
+    // create date pickers, setting currently-selected from and to dates
     var selectedFrom = null;
     var selectedTo = null;
 
@@ -496,6 +485,20 @@ function tableInit(ctx){
       action.find('[data-date-to-for]').datepicker(options);
     inputTo.val(selectedTo);
 
+    // if the radio button is checked and one or both of the datepickers are
+    // empty, populate them with today's date
+    radio.change(function () {
+      var now = new Date();
+
+      if (inputFrom.val() === '') {
+        inputFrom.datepicker('setDate', now);
+      }
+
+      if (inputTo.val() === '') {
+        inputTo.datepicker('setDate', now);
+      }
+    });
+
     // set filter_value based on date pickers when
     // one of their values changes
     var changeHandler = function () {
@@ -553,7 +556,8 @@ function tableInit(ctx){
                 {
                   title: '<label for radio button inside the popup>',
                   name: '<name of the filter action>',
-                  count: <number of items this filter will show>
+                  count: <number of items this filter will show>,
+                  ... additional data for the action ...
                 }
               ]
             }
@@ -567,11 +571,12 @@ function tableInit(ctx){
             filter
 
             the filterName is set on the column filter icon, and corresponds
-            to a value in the table's filters property
+            to a value in the table's filter map
 
             when the filter popup's "Apply" button is clicked, the
             value for the radio button which is checked is passed in the
-            querystring and applied to the queryset on the table
+            querystring, along with a filter_value, and applied to the
+            queryset on the table
           */
           var filterActionRadios = $('#filter-actions-' + ctx.tableName);
 
@@ -587,10 +592,12 @@ function tableInit(ctx){
             var filterName = filterData.name + ':' +
                              filterActionData.action_name;
 
-            if (filterActionData.type === 'toggle') {
-              action = createActionToggle(filterName, filterActionData);
+            if (filterActionData.type === 'toggle' ||
+                filterActionData.type === 'day') {
+              action = createActionRadio(filterName, filterActionData);
             }
             else if (filterActionData.type === 'daterange') {
+              // current values for the from/to dates
               var filterValue = tableParams.filter_value;
 
               action = createActionDateRange(
@@ -601,7 +608,7 @@ function tableInit(ctx){
             }
 
             if (action) {
-              // Setup the current selected filter, default to 'all' if
+              // Setup the current selected filter; default to 'all' if
               // no current filter selected
               var radioInput = action.children('input[name="filter"]');
               if ((tableParams.filter &&
@@ -707,13 +714,12 @@ function tableInit(ctx){
                                           tableParams.filter + "']");
     tableParams.filter_value = checkedFilterValue.val();
 
-    var filterBtn = $("#" + tableParams.filter.split(":")[0]);
-
     /* All === remove filter */
     if (tableParams.filter.match(":all$")) {
       tableParams.filter = null;
-      filterBtnActive(filterBtn, false);
+      tableParams.filter_value = null;
     } else {
+      var filterBtn = $("#" + tableParams.filter.split(":")[0]);
       filterBtnActive(filterBtn, true);
     }
 
diff --git a/bitbake/lib/toaster/toastergui/tablefilter.py b/bitbake/lib/toaster/toastergui/tablefilter.py
index eb053ac..6cbf975 100644
--- a/bitbake/lib/toaster/toastergui/tablefilter.py
+++ b/bitbake/lib/toaster/toastergui/tablefilter.py
@@ -1,10 +1,14 @@
+from datetime import timedelta
 from django.db.models import Q, Max, Min
 from django.utils import dateparse, timezone
+from querysetfilter import QuerysetFilter
 
 class TableFilter(object):
     """
     Stores a filter for a named field, and can retrieve the action
-    requested from the set of actions for that filter
+    requested from the set of actions for that filter;
+    the order in which actions are added governs the order in which they
+    are returned in the JSON for the filter
     """
 
     def __init__(self, name, title):
@@ -12,7 +16,11 @@ class TableFilter(object):
         self.title = title
         self.__filter_action_map = {}
 
+        # retains the ordering of actions
+        self.__filter_action_keys = []
+
     def add_action(self, action):
+        self.__filter_action_keys.append(action.name)
         self.__filter_action_map[action.name] = action
 
     def get_action(self, action_name):
@@ -36,7 +44,8 @@ class TableFilter(object):
         })
 
         # add other filter actions
-        for action_name, filter_action in self.__filter_action_map.iteritems():
+        for action_name in self.__filter_action_keys:
+            filter_action = self.__filter_action_map[action_name]
             obj = filter_action.to_json(queryset)
             obj['action_name'] = action_name
             filter_actions.append(obj)
@@ -47,6 +56,40 @@ class TableFilter(object):
             'filter_actions': filter_actions
         }
 
+class TableFilterQueryHelper(object):
+    def dateStringsToQ(self, field_name, date_from_str, date_to_str):
+        """
+        Convert the date strings from_date_str and to_date_str into a
+        set of args in the form
+
+          {'<field_name>__gte': <date from>, '<field_name>__lte': <date to>}
+
+        where date_from and date_to are Django-timezone-aware dates; then
+        convert that into a Django Q object
+
+        Returns the Q object based on those criteria
+        """
+
+        # one of the values required for the filter is missing, so set
+        # it to the one which was supplied
+        if date_from_str == '':
+            date_from_str = date_to_str
+        elif date_to_str == '':
+            date_to_str = date_from_str
+
+        date_from_naive = dateparse.parse_datetime(date_from_str + ' 00:00:00')
+        date_to_naive = dateparse.parse_datetime(date_to_str + ' 23:59:59')
+
+        tz = timezone.get_default_timezone()
+        date_from = timezone.make_aware(date_from_naive, tz)
+        date_to = timezone.make_aware(date_to_naive, tz)
+
+        args = {}
+        args[field_name + '__gte'] = date_from
+        args[field_name + '__lte'] = date_to
+
+        return Q(**args)
+
 class TableFilterAction(object):
     """
     A filter action which displays in the filter popup for a ToasterTable
@@ -79,7 +122,7 @@ class TableFilterAction(object):
         return {
             'title': self.title,
             'type': self.type,
-            'count': self.queryset_filter.count(queryset)
+            'count': self.filter(queryset).count()
         }
 
 class TableFilterActionToggle(TableFilterAction):
@@ -93,15 +136,70 @@ class TableFilterActionToggle(TableFilterAction):
         super(TableFilterActionToggle, self).__init__(*args)
         self.type = 'toggle'
 
+class TableFilterActionDay(TableFilterAction):
+    """
+    A filter action which filters according to the named datetime field and a
+    string representing a day ("today" or "yesterday")
+    """
+
+    TODAY = 'today'
+    YESTERDAY = 'yesterday'
+
+    def __init__(self, name, title, field, day,
+    queryset_filter = QuerysetFilter(), query_helper = TableFilterQueryHelper()):
+        """
+        field: (string) the datetime field to filter by
+        day: (string) "today" or "yesterday"
+        """
+        super(TableFilterActionDay, self).__init__(
+            name,
+            title,
+            queryset_filter
+        )
+        self.type = 'day'
+        self.field = field
+        self.day = day
+        self.query_helper = query_helper
+
+    def filter(self, queryset):
+        """
+        Apply the day filtering before returning the queryset;
+        this is done here as the value of the filter criteria changes
+        depending on when the filtering is applied
+        """
+
+        criteria = None
+        date_str = None
+        now = timezone.now()
+
+        if self.day == self.YESTERDAY:
+            increment = timedelta(days=1)
+            wanted_date = now - increment
+        else:
+            wanted_date = now
+
+        wanted_date_str = wanted_date.strftime('%Y-%m-%d')
+
+        criteria = self.query_helper.dateStringsToQ(
+            self.field,
+            wanted_date_str,
+            wanted_date_str
+        )
+
+        self.queryset_filter.set_criteria(criteria)
+
+        return self.queryset_filter.filter(queryset)
+
 class TableFilterActionDateRange(TableFilterAction):
     """
     A filter action which will filter the queryset by a date range.
     The date range can be set via set_params()
     """
 
-    def __init__(self, name, title, field, queryset_filter):
+    def __init__(self, name, title, field,
+    queryset_filter = QuerysetFilter(), query_helper = TableFilterQueryHelper()):
         """
-        field: the field to find the max/min range from in the queryset
+        field: (string) the field to find the max/min range from in the queryset
         """
         super(TableFilterActionDateRange, self).__init__(
             name,
@@ -111,9 +209,13 @@ class TableFilterActionDateRange(TableFilterAction):
 
         self.type = 'daterange'
         self.field = field
+        self.query_helper = query_helper
 
     def set_filter_params(self, params):
         """
+        This filter depends on the user selecting some input, so it needs
+        to have its parameters set before its queryset is filtered
+
         params: (str) a string of extra parameters for the filtering
         in the format "2015-12-09,2015-12-11" (from,to); this is passed in the
         querystring and used to set the criteria on the QuerysetFilter
@@ -123,30 +225,18 @@ class TableFilterActionDateRange(TableFilterAction):
         # if params are invalid, return immediately, resetting criteria
         # on the QuerysetFilter
         try:
-            from_date_str, to_date_str = params.split(',')
+            date_from_str, date_to_str = params.split(',')
         except ValueError:
             self.queryset_filter.set_criteria(None)
             return
 
         # one of the values required for the filter is missing, so set
         # it to the one which was supplied
-        if from_date_str == '':
-            from_date_str = to_date_str
-        elif to_date_str == '':
-            to_date_str = from_date_str
-
-        date_from_naive = dateparse.parse_datetime(from_date_str + ' 00:00:00')
-        date_to_naive = dateparse.parse_datetime(to_date_str + ' 23:59:59')
-
-        tz = timezone.get_default_timezone()
-        date_from = timezone.make_aware(date_from_naive, tz)
-        date_to = timezone.make_aware(date_to_naive, tz)
-
-        args = {}
-        args[self.field + '__gte'] = date_from
-        args[self.field + '__lte'] = date_to
-
-        criteria = Q(**args)
+        criteria = self.query_helper.dateStringsToQ(
+            self.field,
+            date_from_str,
+            date_to_str
+        )
         self.queryset_filter.set_criteria(criteria)
 
     def to_json(self, queryset):
@@ -159,7 +249,8 @@ class TableFilterActionDateRange(TableFilterAction):
         data['max'] = queryset.aggregate(Max(self.field))[self.field + '__max']
 
         # a range filter has a count of None, as the number of records it
-        # will select depends on the date range entered
+        # will select depends on the date range entered and we don't know
+        # that ahead of time
         data['count'] = None
 
         return data
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index a9efc0c..b76d1aa 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -32,6 +32,7 @@ import itertools
 from toastergui.tablefilter import TableFilter
 from toastergui.tablefilter import TableFilterActionToggle
 from toastergui.tablefilter import TableFilterActionDateRange
+from toastergui.tablefilter import TableFilterActionDay
 
 class ProjectFilters(object):
     def __init__(self, project_layers):
@@ -65,20 +66,20 @@ class LayersTable(ToasterTable):
 
         criteria = Q(projectlayer__in=self.project_layers)
 
-        in_project_filter_action = TableFilterActionToggle(
+        in_project_action = TableFilterActionToggle(
             "in_project",
             "Layers added to this project",
             QuerysetFilter(criteria)
         )
 
-        not_in_project_filter_action = TableFilterActionToggle(
+        not_in_project_action = TableFilterActionToggle(
             "not_in_project",
             "Layers not added to this project",
             QuerysetFilter(~criteria)
         )
 
-        in_current_project_filter.add_action(in_project_filter_action)
-        in_current_project_filter.add_action(not_in_project_filter_action)
+        in_current_project_filter.add_action(in_project_action)
+        in_current_project_filter.add_action(not_in_project_action)
         self.add_filter(in_current_project_filter)
 
     def setup_queryset(self, *args, **kwargs):
@@ -221,20 +222,20 @@ class MachinesTable(ToasterTable):
             "Filter by project machines"
         )
 
-        in_project_filter_action = TableFilterActionToggle(
+        in_project_action = TableFilterActionToggle(
             "in_project",
             "Machines provided by layers added to this project",
             project_filters.in_project
         )
 
-        not_in_project_filter_action = TableFilterActionToggle(
+        not_in_project_action = TableFilterActionToggle(
             "not_in_project",
             "Machines provided by layers not added to this project",
             project_filters.not_in_project
         )
 
-        in_current_project_filter.add_action(in_project_filter_action)
-        in_current_project_filter.add_action(not_in_project_filter_action)
+        in_current_project_filter.add_action(in_project_action)
+        in_current_project_filter.add_action(not_in_project_action)
         self.add_filter(in_current_project_filter)
 
     def setup_queryset(self, *args, **kwargs):
@@ -354,20 +355,20 @@ class RecipesTable(ToasterTable):
             'Filter by project recipes'
         )
 
-        in_project_filter_action = TableFilterActionToggle(
+        in_project_action = TableFilterActionToggle(
             'in_project',
             'Recipes provided by layers added to this project',
             project_filters.in_project
         )
 
-        not_in_project_filter_action = TableFilterActionToggle(
+        not_in_project_action = TableFilterActionToggle(
             'not_in_project',
             'Recipes provided by layers not added to this project',
             project_filters.not_in_project
         )
 
-        table_filter.add_action(in_project_filter_action)
-        table_filter.add_action(not_in_project_filter_action)
+        table_filter.add_action(in_project_action)
+        table_filter.add_action(not_in_project_action)
         self.add_filter(table_filter)
 
     def setup_queryset(self, *args, **kwargs):
@@ -1139,20 +1140,20 @@ class BuildsTable(ToasterTable):
             'Filter builds by outcome'
         )
 
-        successful_builds_filter_action = TableFilterActionToggle(
+        successful_builds_action = TableFilterActionToggle(
             'successful_builds',
             'Successful builds',
             QuerysetFilter(Q(outcome=Build.SUCCEEDED))
         )
 
-        failed_builds_filter_action = TableFilterActionToggle(
+        failed_builds_action = TableFilterActionToggle(
             'failed_builds',
             'Failed builds',
             QuerysetFilter(Q(outcome=Build.FAILED))
         )
 
-        outcome_filter.add_action(successful_builds_filter_action)
-        outcome_filter.add_action(failed_builds_filter_action)
+        outcome_filter.add_action(successful_builds_action)
+        outcome_filter.add_action(failed_builds_action)
         self.add_filter(outcome_filter)
 
         # started on
@@ -1161,14 +1162,29 @@ class BuildsTable(ToasterTable):
             'Filter by date when build was started'
         )
 
-        by_started_date_range_filter_action = TableFilterActionDateRange(
+        started_today_action = TableFilterActionDay(
+            'today',
+            'Today\'s builds',
+            'started_on',
+            'today'
+        )
+
+        started_yesterday_action = TableFilterActionDay(
+            'yesterday',
+            'Yesterday\'s builds',
+            'started_on',
+            'yesterday'
+        )
+
+        by_started_date_range_action = TableFilterActionDateRange(
             'date_range',
             'Build date range',
-            'started_on',
-            QuerysetFilter()
+            'started_on'
         )
 
-        started_on_filter.add_action(by_started_date_range_filter_action)
+        started_on_filter.add_action(started_today_action)
+        started_on_filter.add_action(started_yesterday_action)
+        started_on_filter.add_action(by_started_date_range_action)
         self.add_filter(started_on_filter)
 
         # completed on
@@ -1177,14 +1193,29 @@ class BuildsTable(ToasterTable):
             'Filter by date when build was completed'
         )
 
-        by_completed_date_range_filter_action = TableFilterActionDateRange(
+        completed_today_action = TableFilterActionDay(
+            'today',
+            'Today\'s builds',
+            'completed_on',
+            'today'
+        )
+
+        completed_yesterday_action = TableFilterActionDay(
+            'yesterday',
+            'Yesterday\'s builds',
+            'completed_on',
+            'yesterday'
+        )
+
+        by_completed_date_range_action = TableFilterActionDateRange(
             'date_range',
             'Build date range',
-            'completed_on',
-            QuerysetFilter()
+            'completed_on'
         )
 
-        completed_on_filter.add_action(by_completed_date_range_filter_action)
+        completed_on_filter.add_action(completed_today_action)
+        completed_on_filter.add_action(completed_yesterday_action)
+        completed_on_filter.add_action(by_completed_date_range_action)
         self.add_filter(completed_on_filter)
 
         # failed tasks
@@ -1195,18 +1226,18 @@ class BuildsTable(ToasterTable):
 
         criteria = Q(task_build__outcome=Task.OUTCOME_FAILED)
 
-        with_failed_tasks_filter_action = TableFilterActionToggle(
+        with_failed_tasks_action = TableFilterActionToggle(
             'with_failed_tasks',
             'Builds with failed tasks',
             QuerysetFilter(criteria)
         )
 
-        without_failed_tasks_filter_action = TableFilterActionToggle(
+        without_failed_tasks_action = TableFilterActionToggle(
             'without_failed_tasks',
             'Builds without failed tasks',
             QuerysetFilter(~criteria)
         )
 
-        failed_tasks_filter.add_action(with_failed_tasks_filter_action)
-        failed_tasks_filter.add_action(without_failed_tasks_filter_action)
+        failed_tasks_filter.add_action(with_failed_tasks_action)
+        failed_tasks_filter.add_action(without_failed_tasks_action)
         self.add_filter(failed_tasks_filter)
diff --git a/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
index 2e32edb..bf13a66 100644
--- a/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
+++ b/bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
@@ -18,7 +18,7 @@
       {% include 'mrb_section.html' %}
     {% endwith %}
 
-    <h1  class="page-header top-air" data-role="page-title"></h1>
+    <h1 class="page-header top-air" data-role="page-title"></h1>
 
     {% url 'builds' as xhr_table_url %}
     {% include 'toastertable.html' %}
diff --git a/bitbake/lib/toaster/toastergui/templates/toastertable.html b/bitbake/lib/toaster/toastergui/templates/toastertable.html
index 98a715f..f0a3aed 100644
--- a/bitbake/lib/toaster/toastergui/templates/toastertable.html
+++ b/bitbake/lib/toaster/toastergui/templates/toastertable.html
@@ -32,8 +32,11 @@
       <a href="#" class="add-on btn remove-search-btn-{{table_name}}" tabindex="-1">
         <i class="icon-remove"></i>
       </a>
-      <button class="btn search-submit-{{table_name}}" >Search</button>
-      <button class="btn btn-link remove-search-btn-{{table_name}}">Show {{title|lower}}
+      <button class="btn search-submit-{{table_name}}">
+        Search
+      </button>
+      <button class="btn btn-link show-all-{{table_name}}">
+        Show {{title|lower}}
       </button>
     </form>
   </div>
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 13/17] toaster: toastergui Convert project builds page to ToasterTable
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (11 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 12/17] toaster: toastergui Implement "today" and "yesterday" filters Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 14/17] toaster: tests Fix Django tests for new ToasterTable pages Elliot Smith
                   ` (4 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Use the all builds ToasterTable as the basis for the project builds
ToasterTable.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 .../toaster/toastergui/static/js/projecttopbar.js  |   9 ++
 bitbake/lib/toaster/toastergui/tables.py           | 169 +++++++++++++++++----
 .../toastergui/templates/baseprojectpage.html      |   1 +
 .../toaster/toastergui/templates/mrb_section.html  |   2 +-
 .../templates/projectbuilds-toastertable.html      |  56 +++++++
 bitbake/lib/toaster/toastergui/urls.py             |   6 +-
 bitbake/lib/toaster/toastergui/views.py            |  16 +-
 bitbake/lib/toaster/toastergui/widgets.py          |   1 -
 8 files changed, 223 insertions(+), 37 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/templates/projectbuilds-toastertable.html

diff --git a/bitbake/lib/toaster/toastergui/static/js/projecttopbar.js b/bitbake/lib/toaster/toastergui/static/js/projecttopbar.js
index b6ad380..58a32a0 100644
--- a/bitbake/lib/toaster/toastergui/static/js/projecttopbar.js
+++ b/bitbake/lib/toaster/toastergui/static/js/projecttopbar.js
@@ -7,7 +7,10 @@ function projectTopBarInit(ctx) {
   var projectName = $("#project-name");
   var projectNameFormToggle = $("#project-change-form-toggle");
   var projectNameChangeCancel = $("#project-name-change-cancel");
+
+  // this doesn't exist for command-line builds
   var newBuildTargetInput = $("#build-input");
+
   var newBuildTargetBuildBtn = $("#build-button");
   var selectedTarget;
 
@@ -42,6 +45,12 @@ function projectTopBarInit(ctx) {
       $(this).parent().removeClass('active');
   });
 
+  if (!newBuildTargetInput.length) {
+    return;
+  }
+
+  /* the following only applies for non-command-line projects */
+
   /* Recipe build input functionality */
   if (ctx.numProjectLayers > 0 && ctx.machine){
     newBuildTargetInput.removeAttr("disabled");
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index b76d1aa..1449acc 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -23,9 +23,11 @@ from toastergui.widgets import ToasterTable
 from toastergui.querysetfilter import QuerysetFilter
 from orm.models import Recipe, ProjectLayer, Layer_Version, Machine, Project
 from orm.models import CustomImageRecipe, Package, Build, LogMessage, Task
+from orm.models import ProjectTarget
 from django.db.models import Q, Max, Count
 from django.conf.urls import url
-from django.core.urlresolvers import reverse
+from django.core.urlresolvers import reverse, resolve
+from django.http import HttpResponse
 from django.views.generic import TemplateView
 import itertools
 
@@ -888,30 +890,45 @@ class BuildsTable(ToasterTable):
     def __init__(self, *args, **kwargs):
         super(BuildsTable, self).__init__(*args, **kwargs)
         self.default_orderby = '-completed_on'
-        self.title = 'All builds'
         self.static_context_extra['Build'] = Build
         self.static_context_extra['Task'] = Task
 
+        # attributes that are overridden in subclasses
+
+        # title for the page
+        self.title = ''
+
+        # 'project' or 'all'; determines how the mrb (most recent builds)
+        # section is displayed
+        self.mrb_type = ''
+
+    def get_builds(self):
+        """
+        overridden in ProjectBuildsTable to return builds for a
+        single project
+        """
+        return Build.objects.all()
+
     def get_context_data(self, **kwargs):
         context = super(BuildsTable, self).get_context_data(**kwargs)
 
         # for the latest builds section
-        queryset = Build.objects.all()
+        builds = self.get_builds()
 
         finished_criteria = Q(outcome=Build.SUCCEEDED) | Q(outcome=Build.FAILED)
 
         latest_builds = itertools.chain(
-            queryset.filter(outcome=Build.IN_PROGRESS).order_by("-started_on"),
-            queryset.filter(finished_criteria).order_by("-completed_on")[:3]
+            builds.filter(outcome=Build.IN_PROGRESS).order_by("-started_on"),
+            builds.filter(finished_criteria).order_by("-completed_on")[:3]
         )
 
         context['mru'] = list(latest_builds)
-        context['mrb_type'] = 'all'
+        context['mrb_type'] = self.mrb_type
 
         return context
 
     def setup_queryset(self, *args, **kwargs):
-        queryset = Build.objects.all()
+        queryset = self.get_builds()
 
         # don't include in progress builds
         queryset = queryset.exclude(outcome=Build.IN_PROGRESS)
@@ -1033,19 +1050,6 @@ class BuildsTable(ToasterTable):
         {% endif %}
         '''
 
-        project_template = '''
-        {% load project_url_tag %}
-        <a href="{% project_url data.project %}">
-            {{data.project.name}}
-        </a>
-        {% if data.project.is_default %}
-            <i class="icon-question-sign get-help hover-help" title=""
-               data-original-title="This project shows information about
-               the builds you start from the command line while Toaster is
-               running" style="visibility: hidden;"></i>
-        {% endif %}
-        '''
-
         self.add_column(title='Outcome',
                         help_text='Final state of the build (successful \
                                    or failed)',
@@ -1127,12 +1131,6 @@ class BuildsTable(ToasterTable):
                         static_data_name='image_files',
                         static_data_template=image_files_template)
 
-        self.add_column(title='Project',
-                        hideable=True,
-                        orderable=False,
-                        static_data_name='project-name',
-                        static_data_template=project_template)
-
     def setup_filters(self, *args, **kwargs):
         # outcomes
         outcome_filter = TableFilter(
@@ -1241,3 +1239,122 @@ class BuildsTable(ToasterTable):
         failed_tasks_filter.add_action(with_failed_tasks_action)
         failed_tasks_filter.add_action(without_failed_tasks_action)
         self.add_filter(failed_tasks_filter)
+
+    def post(self, request, *args, **kwargs):
+        """ Process HTTP POSTs which make build requests """
+
+        project = Project.objects.get(pk=kwargs['pid'])
+
+        if 'buildCancel' in request.POST:
+            for i in request.POST['buildCancel'].strip().split(" "):
+                try:
+                    br = BuildRequest.objects.select_for_update().get(project = project, pk = i, state__lte = BuildRequest.REQ_QUEUED)
+                    br.state = BuildRequest.REQ_DELETED
+                    br.save()
+                except BuildRequest.DoesNotExist:
+                    pass
+
+        if 'buildDelete' in request.POST:
+            for i in request.POST['buildDelete'].strip().split(" "):
+                try:
+                    BuildRequest.objects.select_for_update().get(project = project, pk = i, state__lte = BuildRequest.REQ_DELETED).delete()
+                except BuildRequest.DoesNotExist:
+                    pass
+
+        if 'targets' in request.POST:
+            ProjectTarget.objects.filter(project = project).delete()
+            s = str(request.POST['targets'])
+            for t in s.translate(None, ";%|\"").split(" "):
+                if ":" in t:
+                    target, task = t.split(":")
+                else:
+                    target = t
+                    task = ""
+                ProjectTarget.objects.create(project = project,
+                                             target = target,
+                                             task = task)
+            project.schedule_build()
+
+        # redirect back to builds page so any new builds in progress etc.
+        # are visible
+        response = HttpResponse()
+        response.status_code = 302
+        response['Location'] = request.build_absolute_uri()
+        return response
+
+class AllBuildsTable(BuildsTable):
+    """ Builds page for all builds """
+
+    def __init__(self, *args, **kwargs):
+        super(AllBuildsTable, self).__init__(*args, **kwargs)
+        self.title = 'All project builds'
+        self.mrb_type = 'all'
+
+    def setup_columns(self, *args, **kwargs):
+        """
+        All builds page shows a column for the project
+        """
+
+        super(AllBuildsTable, self).setup_columns(*args, **kwargs)
+
+        project_template = '''
+        {% load project_url_tag %}
+        <a href="{% project_url data.project %}">
+            {{data.project.name}}
+        </a>
+        {% if data.project.is_default %}
+            <i class="icon-question-sign get-help hover-help" title=""
+               data-original-title="This project shows information about
+               the builds you start from the command line while Toaster is
+               running" style="visibility: hidden;"></i>
+        {% endif %}
+        '''
+
+        self.add_column(title='Project',
+                        hideable=True,
+                        orderable=False,
+                        static_data_name='project-name',
+                        static_data_template=project_template)
+
+class ProjectBuildsTable(BuildsTable):
+    """
+    Builds page for a single project; a BuildsTable, with the queryset
+    filtered by project
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(ProjectBuildsTable, self).__init__(*args, **kwargs)
+        self.title = 'All project builds'
+        self.mrb_type = 'project'
+
+        # set from the querystring
+        self.project_id = None
+
+    def setup_queryset(self, *args, **kwargs):
+        """
+        NOTE: self.project_id must be set before calling super(),
+        as it's used in setup_queryset()
+        """
+        self.project_id = kwargs['pid']
+        super(ProjectBuildsTable, self).setup_queryset(*args, **kwargs)
+
+        project = Project.objects.get(pk=self.project_id)
+        self.queryset = self.queryset.filter(project=project)
+
+    def get_context_data(self, **kwargs):
+        """
+        NOTE: self.project_id must be set before calling super(),
+        as it's used in get_context_data()
+        """
+        self.project_id = kwargs['pid']
+
+        context = super(ProjectBuildsTable, self).get_context_data(**kwargs)
+        context['project'] = Project.objects.get(pk=self.project_id)
+
+        return context
+
+    def get_builds(self):
+        """ override: only return builds for the relevant project """
+
+        project = Project.objects.get(pk=self.project_id)
+        return Build.objects.filter(project=project)
diff --git a/bitbake/lib/toaster/toastergui/templates/baseprojectpage.html b/bitbake/lib/toaster/toastergui/templates/baseprojectpage.html
index 1f45be4..b143b78 100644
--- a/bitbake/lib/toaster/toastergui/templates/baseprojectpage.html
+++ b/bitbake/lib/toaster/toastergui/templates/baseprojectpage.html
@@ -1,4 +1,5 @@
 {% extends "base.html" %}
+
 {% load projecttags %}
 {% load humanize %}
 
diff --git a/bitbake/lib/toaster/toastergui/templates/mrb_section.html b/bitbake/lib/toaster/toastergui/templates/mrb_section.html
index 52b3f1a..2f4820c 100644
--- a/bitbake/lib/toaster/toastergui/templates/mrb_section.html
+++ b/bitbake/lib/toaster/toastergui/templates/mrb_section.html
@@ -6,7 +6,7 @@
 {%if mru and mru.count > 0%}
 
   {%if mrb_type == 'project' %}
-      <h2>
+      <h2 class="page-header">
       Latest project builds
 
       {% if project.is_default %}
diff --git a/bitbake/lib/toaster/toastergui/templates/projectbuilds-toastertable.html b/bitbake/lib/toaster/toastergui/templates/projectbuilds-toastertable.html
new file mode 100644
index 0000000..6d7e10b
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/templates/projectbuilds-toastertable.html
@@ -0,0 +1,56 @@
+{% extends 'base.html' %}
+
+{% load static %}
+
+{% block extraheadcontent %}
+  <link rel="stylesheet" href="{% static 'css/jquery-ui.min.css' %}" type='text/css'>
+  <link rel="stylesheet" href="{% static 'css/jquery-ui.structure.min.css' %}" type='text/css'>
+  <link rel="stylesheet" href="{% static 'css/jquery-ui.theme.min.css' %}" type='text/css'>
+  <script src="{% static 'js/jquery-ui.min.js' %}">
+  </script>
+{% endblock %}
+
+{% block title %} {{title}} - {{project.name}} - Toaster {% endblock %}
+
+{% block pagecontent %}
+
+  {% include "projecttopbar.html" %}
+
+  <div class="row-fluid">
+    {% with mru=mru mrb_type=mrb_type %}
+      {% include 'mrb_section.html' %}
+    {% endwith %}
+
+    <h2 class="page-header top-air" data-role="page-title"></h2>
+
+    {% url 'projectbuilds' project.id as xhr_table_url %}
+    {% include 'toastertable.html' %}
+  </div>
+
+  <script>
+    $(document).ready(function () {
+      // title
+      var tableElt = $("#{{table_name}}");
+      var titleElt = $("[data-role='page-title']");
+
+      tableElt.on("table-done", function (e, total, tableParams) {
+        var title = "All project builds";
+
+        if (tableParams.search || tableParams.filter) {
+          if (total === 0) {
+            title = "No project builds found";
+          }
+          else if (total > 0) {
+            title = total + " project build" + (total > 1 ? 's' : '') + " found";
+          }
+        }
+
+        titleElt.text(title);
+      });
+
+      // highlight builds tab
+      $("#topbar-builds-tab").addClass("active")
+    });
+  </script>
+
+{% endblock %}
diff --git a/bitbake/lib/toaster/toastergui/urls.py b/bitbake/lib/toaster/toastergui/urls.py
index 707b7d5..c8c1c6a 100644
--- a/bitbake/lib/toaster/toastergui/urls.py
+++ b/bitbake/lib/toaster/toastergui/urls.py
@@ -28,7 +28,7 @@ urlpatterns = patterns('toastergui.views',
         url(r'^landing/$', 'landing', name='landing'),
 
         url(r'^builds/$',
-            tables.BuildsTable.as_view(template_name="builds-toastertable.html"),
+            tables.AllBuildsTable.as_view(template_name="builds-toastertable.html"),
             name='all-builds'),
 
         # build info navigation
@@ -83,7 +83,9 @@ urlpatterns = patterns('toastergui.views',
 
         url(r'^project/(?P<pid>\d+)/$', 'project', name='project'),
         url(r'^project/(?P<pid>\d+)/configuration$', 'projectconf', name='projectconf'),
-        url(r'^project/(?P<pid>\d+)/builds/$', 'projectbuilds', name='projectbuilds'),
+        url(r'^project/(?P<pid>\d+)/builds/$',
+            tables.ProjectBuildsTable.as_view(template_name="projectbuilds-toastertable.html"),
+            name='projectbuilds'),
 
         # the import layer is a project-specific functionality;
         url(r'^project/(?P<pid>\d+)/importlayer$', 'importlayer', name='importlayer'),
diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
index 295773f..fbae36c 100755
--- a/bitbake/lib/toaster/toastergui/views.py
+++ b/bitbake/lib/toaster/toastergui/views.py
@@ -91,6 +91,7 @@ def landing(request):
 
     return render(request, 'landing.html', context)
 
+"""
 # returns a list for most recent builds;
 def _get_latest_builds(prj=None):
     queryset = Build.objects.all()
@@ -101,8 +102,9 @@ def _get_latest_builds(prj=None):
     return list(itertools.chain(
         queryset.filter(outcome=Build.IN_PROGRESS).order_by("-started_on"),
         queryset.filter(outcome__lt=Build.IN_PROGRESS).order_by("-started_on")[:3] ))
+"""
 
-
+"""
 # a JSON-able dict of recent builds; for use in the Project page, xhr_ updates,  and other places, as needed
 def _project_recent_build_list(prj):
     data = []
@@ -131,8 +133,7 @@ def _project_recent_build_list(prj):
         data.append(d)
 
     return data
-
-
+"""
 
 def objtojson(obj):
     from django.db.models.query import QuerySet
@@ -1915,6 +1916,7 @@ if True:
         ''' The exception raised on invalid POST requests '''
         pass
 
+    """
     # helper function, to be used on "all builds" and "project builds" pages
     def _build_list_helper(request, queryset_all, redirect_page, pid=None):
         default_orderby = 'completed_on:-'
@@ -2119,6 +2121,7 @@ if True:
         # merge daterange values
         context.update(context_date)
         return context, pagesize, orderby
+    """
 
 
 
@@ -2256,7 +2259,7 @@ if True:
             "completedbuilds": Build.objects.exclude(outcome = Build.IN_PROGRESS).filter(project_id = pid),
             "prj" : {"name": prj.name, },
             "buildrequests" : prj.build_set.filter(outcome=Build.IN_PROGRESS),
-            "builds" : _project_recent_build_list(prj),
+            #"builds" : _project_recent_build_list(prj),
             "layers" :  map(lambda x: {
                         "id": x.layercommit.pk,
                         "orderid": x.pk,
@@ -2827,10 +2830,8 @@ if True:
     # will set the GET parameters and redirect back to the
     # all-builds or projectbuilds page as appropriate;
     # TODO don't use exceptions to control program flow
-    @_template_renderer('projectbuilds.html')
+    """
     def projectbuilds(request, pid):
-        prj = Project.objects.get(id = pid)
-
         if request.method == "POST":
             # process any build request
 
@@ -2880,6 +2881,7 @@ if True:
         context['mru'] = _get_latest_builds(prj)
 
         return context
+    """
 
 
     def _file_name_for_artifact(b, artifact_type, artifact_id):
diff --git a/bitbake/lib/toaster/toastergui/widgets.py b/bitbake/lib/toaster/toastergui/widgets.py
index 47de30d..bc081b8 100644
--- a/bitbake/lib/toaster/toastergui/widgets.py
+++ b/bitbake/lib/toaster/toastergui/widgets.py
@@ -61,7 +61,6 @@ class ToasterTable(TemplateView):
 
         self.total_count = 0
         self.static_context_extra = {}
-        self.filter_actions = {}
         self.empty_state = "Sorry - no data found"
         self.default_orderby = ""
 
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 14/17] toaster: tests Fix Django tests for new ToasterTable pages
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (12 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 13/17] toaster: toastergui Convert project builds page to ToasterTable Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 15/17] toaster: toastergui Don't hide all elements with .col class Elliot Smith
                   ` (3 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

The Django command-line tests can no longer test the content
of the projects/, builds/ and projectbuilds/ pages, as
ToasterTable pages are populated by JavaScript.

Fix/remove affected tests by converting them to tests on the
JSON returned by the ToasterTable.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/tests.py | 255 +++++++++++++++++++++-----------
 1 file changed, 170 insertions(+), 85 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/tests.py b/bitbake/lib/toaster/toastergui/tests.py
index c927fe1..b62faff 100644
--- a/bitbake/lib/toaster/toastergui/tests.py
+++ b/bitbake/lib/toaster/toastergui/tests.py
@@ -38,11 +38,14 @@ import toastergui
 
 from toastergui.tables import SoftwareRecipesTable
 import json
+from datetime import timedelta
 from bs4 import BeautifulSoup
 import re
 import string
+import json
 
 PROJECT_NAME = "test project"
+PROJECT_NAME2 = "test project 2"
 CLI_BUILDS_PROJECT_NAME = 'Command line builds'
 
 class ViewTests(TestCase):
@@ -54,14 +57,36 @@ class ViewTests(TestCase):
         release = Release.objects.create(name="test release",
                                          branch_name="master",
                                          bitbake_version=bbv)
+        release2 = Release.objects.create(name="test release 2",
+                                          branch_name="master",
+                                          bitbake_version=bbv)
+
         self.project = Project.objects.create_project(name=PROJECT_NAME,
                                                       release=release)
+
+        Project.objects.create_project(name=PROJECT_NAME2,
+                                       release=release2)
+
         now = timezone.now()
+        later = now + timedelta(days=1)
 
         build = Build.objects.create(project=self.project,
                                      started_on=now,
                                      completed_on=now)
 
+        # for testing BuildsTable
+        Build.objects.create(project=self.project,
+                             started_on=now,
+                             completed_on=now,
+                             outcome=Build.SUCCEEDED,
+                             machine="raspberrypi2")
+
+        Build.objects.create(project=self.project,
+                             started_on=later,
+                             completed_on=later,
+                             outcome=Build.FAILED,
+                             machine="qemux86")
+
         layersrc = LayerSource.objects.create(sourcetype=LayerSource.TYPE_IMPORTED)
         self.priority = ReleaseLayerSourcePriority.objects.create(release=release,
                                                                   layer_source=layersrc)
@@ -172,8 +197,7 @@ class ViewTests(TestCase):
         response = self.client.get(reverse('all-projects'), follow=True)
         self.assertEqual(response.status_code, 200)
         self.assertTrue(response['Content-Type'].startswith('text/html'))
-        self.assertTemplateUsed(response, "projects.html")
-        self.assertTrue(PROJECT_NAME in response.content)
+        self.assertTemplateUsed(response, "projects-toastertable.html")
 
     def test_get_json_call_returns_json(self):
         """Test for all projects output in json format"""
@@ -191,13 +215,6 @@ class ViewTests(TestCase):
         self.assertTrue(PROJECT_NAME in [x["name"] for x in data["rows"]])
         self.assertTrue("id" in data["rows"][0])
 
-        self.assertEqual(sorted(data["rows"][0]),
-                         ['bitbake_version_id', 'created', 'id',
-                          'is_default', 'layersTypeAheadUrl', 'name',
-                          'num_builds', 'projectBuildsUrl', 'projectPageUrl',
-                          'recipesTypeAheadUrl', 'release_id',
-                          'short_description', 'updated', 'user_id'])
-
     def test_typeaheads(self):
         """Test typeahead ReST API"""
         layers_url = reverse('xhr_layerstypeahead', args=(self.project.id,))
@@ -450,7 +467,7 @@ class ViewTests(TestCase):
             all_data = get_data(table)
 
             self.assertTrue(len(all_data['rows']) > 1,
-                            "Cannot test on a table with < 1 row")
+                            "Cannot test on the table %s with < 1 row" % name)
 
             if table.default_orderby:
                 row_one = all_data['rows'][0][table.default_orderby.strip("-")]
@@ -512,16 +529,20 @@ class ViewTests(TestCase):
                         # This is the name of the filter:action
                         # e.g. project_filter:not_in_project
                         filter_string = "%s:%s" % (column['filter_name'],
-                                                   filter_action['name'])
+                                                   filter_action['action_name'])
                         # Now get the data with the filter applied
                         filtered_data = get_data(table_cls(),
                                                  {"filter" : filter_string})
-                        self.assertEqual(len(filtered_data['rows']),
-                                         int(filter_action['count']),
-                                         "We added a table filter for %s but "
-                                         "the number of rows returned was not "
-                                         "what the filter info said there "
-                                         "would be" % name)
+
+                        # date range filter actions can't specify the
+                        # number of results they return, so their count is 0
+                        if filter_action['count'] != None:
+                            self.assertEqual(len(filtered_data['rows']),
+                                             int(filter_action['count']),
+                                             "We added a table filter for %s but "
+                                             "the number of rows returned was not "
+                                             "what the filter info said there "
+                                             "would be" % name)
 
 
             # Test search functionality on the table
@@ -673,6 +694,10 @@ class AllProjectsPageTests(TestCase):
                                                      value=self.MACHINE_NAME)
         project_var.save()
 
+    def _get_row_for_project(self, data, project_id):
+        """ Get the object representing the table data for a project """
+        return [row for row in data['rows'] if row['id'] == project_id][0]
+
     def test_default_project_hidden(self):
         """ The default project should be hidden if it has no builds """
         params = {"count": 10, "orderby": "updated:-", "page": 1}
@@ -688,11 +713,20 @@ class AllProjectsPageTests(TestCase):
         self._add_build_to_default_project()
 
         params = {"count": 10, "orderby": "updated:-", "page": 1}
-        response = self.client.get(reverse('all-projects'), params)
 
-        self.assertTrue('tr class="data"' in response.content,
-                        'should be a project row in the page')
-        self.assertTrue(CLI_BUILDS_PROJECT_NAME in response.content,
+        response = self.client.get(
+            reverse('all-projects'),
+            {'format': 'json'},
+            params
+        )
+
+        data = json.loads(response.content)
+
+        # find the row for the default project
+        default_project_row = self._get_row_for_project(data, self.default_project.id)
+
+        # check its name template has the correct text
+        self.assertEqual(default_project_row['name'], CLI_BUILDS_PROJECT_NAME,
                         'default project "cli builds" should be in page')
 
     def test_default_project_release(self):
@@ -706,24 +740,32 @@ class AllProjectsPageTests(TestCase):
         # another project to test, which should show release
         self._add_non_default_project()
 
-        response = self.client.get(reverse('all-projects'), follow=True)
-        soup = BeautifulSoup(response.content)
+        response = self.client.get(
+            reverse('all-projects'),
+            {'format': 'json'},
+            follow=True
+        )
+
+        data = json.loads(response.content)
+
+        # used to find the correct span in the template output
+        attrs = {'data-project-field': 'release'}
+
+        # find the row for the default project
+        default_project_row = self._get_row_for_project(data, self.default_project.id)
 
-        # check the release cell for the default project
-        attrs = {'data-project': str(self.default_project.id)}
-        rows = soup.find_all('tr', attrs=attrs)
-        self.assertEqual(len(rows), 1, 'should be one row for default project')
-        cells = rows[0].find_all('td', attrs={'data-project-field': 'release'})
-        self.assertEqual(len(cells), 1, 'should be one release cell')
-        text = cells[0].select('span.muted')[0].text
+        # check the release text for the default project
+        soup = BeautifulSoup(default_project_row['static:release'])
+        text = soup.find('span', attrs=attrs).select('span.muted')[0].text
         self.assertEqual(text, 'Not applicable',
                          'release should be not applicable for default project')
 
+        # find the row for the default project
+        other_project_row = self._get_row_for_project(data, self.project.id)
+
         # check the link in the release cell for the other project
-        attrs = {'data-project': str(self.project.id)}
-        rows = soup.find_all('tr', attrs=attrs)
-        cells = rows[0].find_all('td', attrs={'data-project-field': 'release'})
-        text = cells[0].select('a')[0].text
+        soup = BeautifulSoup(other_project_row['static:release'])
+        text = soup.find('span', attrs=attrs).select('a')[0].text.strip()
         self.assertEqual(text, self.release.name,
                          'release name should be shown for non-default project')
 
@@ -738,24 +780,32 @@ class AllProjectsPageTests(TestCase):
         # another project to test, which should show machine
         self._add_non_default_project()
 
-        response = self.client.get(reverse('all-projects'), follow=True)
-        soup = BeautifulSoup(response.content)
+        response = self.client.get(
+            reverse('all-projects'),
+            {'format': 'json'},
+            follow=True
+        )
+
+        data = json.loads(response.content)
+
+        # used to find the correct span in the template output
+        attrs = {'data-project-field': 'machine'}
+
+        # find the row for the default project
+        default_project_row = self._get_row_for_project(data, self.default_project.id)
 
         # check the machine cell for the default project
-        attrs = {'data-project': str(self.default_project.id)}
-        rows = soup.find_all('tr', attrs=attrs)
-        self.assertEqual(len(rows), 1, 'should be one row for default project')
-        cells = rows[0].find_all('td', attrs={'data-project-field': 'machine'})
-        self.assertEqual(len(cells), 1, 'should be one machine cell')
-        text = cells[0].select('span.muted')[0].text
+        soup = BeautifulSoup(default_project_row['static:machine'])
+        text = soup.find('span', attrs=attrs).select('span.muted')[0].text.strip()
         self.assertEqual(text, 'Not applicable',
-                         'machine should be not applicable for default project')
+            'machine should be not applicable for default project')
+
+        # find the row for the default project
+        other_project_row = self._get_row_for_project(data, self.project.id)
 
         # check the link in the machine cell for the other project
-        attrs = {'data-project': str(self.project.id)}
-        rows = soup.find_all('tr', attrs=attrs)
-        cells = rows[0].find_all('td', attrs={'data-project-field': 'machine'})
-        text = cells[0].select('a')[0].text
+        soup = BeautifulSoup(other_project_row['static:machine'])
+        text = soup.find('span', attrs=attrs).find('a').text.strip()
         self.assertEqual(text, self.MACHINE_NAME,
                          'machine name should be shown for non-default project')
 
@@ -769,24 +819,33 @@ class AllProjectsPageTests(TestCase):
         # need a build, otherwise project doesn't display at all
         self._add_build_to_default_project()
 
-        # another project to test, which should show machine
+        # another project to test
         self._add_non_default_project()
 
-        response = self.client.get(reverse('all-projects'), follow=True)
-        soup = BeautifulSoup(response.content)
+        response = self.client.get(
+            reverse('all-projects'),
+            {'format': 'json'},
+            follow=True
+        )
+
+        data = json.loads(response.content)
 
-        # link for default project
-        row = soup.find('tr', attrs={'data-project': self.default_project.id})
-        cell = row.find('td', attrs={'data-project-field': 'name'})
+        # find the row for the default project
+        default_project_row = self._get_row_for_project(data, self.default_project.id)
+
+        # check the link on the name field
+        soup = BeautifulSoup(default_project_row['static:name'])
         expected_url = reverse('projectbuilds', args=(self.default_project.id,))
-        self.assertEqual(cell.find('a')['href'], expected_url,
+        self.assertEqual(soup.find('a')['href'], expected_url,
                          'link on default project name should point to builds')
 
-        # link for other project
-        row = soup.find('tr', attrs={'data-project': self.project.id})
-        cell = row.find('td', attrs={'data-project-field': 'name'})
+        # find the row for the other project
+        other_project_row = self._get_row_for_project(data, self.project.id)
+
+        # check the link for the other project
+        soup = BeautifulSoup(other_project_row['static:name'])
         expected_url = reverse('project', args=(self.project.id,))
-        self.assertEqual(cell.find('a')['href'], expected_url,
+        self.assertEqual(soup.find('a')['href'], expected_url,
                          'link on project name should point to configuration')
 
 class ProjectBuildsPageTests(TestCase):
@@ -846,9 +905,9 @@ class ProjectBuildsPageTests(TestCase):
     def _get_rows_for_project(self, project_id):
         """ Helper to retrieve HTML rows for a project """
         url = reverse("projectbuilds", args=(project_id,))
-        response = self.client.get(url, follow=True)
-        soup = BeautifulSoup(response.content)
-        return soup.select('tr[class="data"]')
+        response = self.client.get(url, {'format': 'json'}, follow=True)
+        data = json.loads(response.content)
+        return data['rows']
 
     def test_show_builds_for_project(self):
         """ Builds for a project should be displayed """
@@ -889,10 +948,14 @@ class ProjectBuildsPageTests(TestCase):
         """ Task should be shown as suffix on build name """
         build = Build.objects.create(**self.project1_build_success)
         Target.objects.create(build=build, target='bash', task='clean')
-        url = reverse("projectbuilds", args=(self.project1.id,))
-        response = self.client.get(url, follow=True)
-        result = re.findall('^ +bash:clean$', response.content, re.MULTILINE)
-        self.assertEqual(len(result), 2)
+
+        url = reverse('projectbuilds', args=(self.project1.id,))
+        response = self.client.get(url, {'format': 'json'}, follow=True)
+        data = json.loads(response.content)
+        cell = data['rows'][0]['static:target']
+
+        result = re.findall('^ +bash:clean', cell, re.MULTILINE)
+        self.assertEqual(len(result), 1)
 
     def test_cli_builds_hides_tabs(self):
         """
@@ -952,32 +1015,46 @@ class AllBuildsPageTests(TestCase):
             "outcome": Build.SUCCEEDED
         }
 
+    def _get_row_for_build(self, data, build_id):
+        """ Get the object representing the table data for a project """
+        return [row for row in data['rows']
+                    if row['id'] == build_id][0]
+
     def test_show_tasks_in_allbuilds(self):
         """ Task should be shown as suffix on build name """
         build = Build.objects.create(**self.project1_build_success)
         Target.objects.create(build=build, target='bash', task='clean')
+
         url = reverse('all-builds')
-        response = self.client.get(url, follow=True)
-        result = re.findall('bash:clean', response.content, re.MULTILINE)
-        self.assertEqual(len(result), 3)
+        response = self.client.get(url, {'format': 'json'}, follow=True)
+        data = json.loads(response.content)
+        cell = data['rows'][0]['static:target']
 
-    def test_no_run_again_for_cli_build(self):
-        """ "Run again" button should not be shown for command-line builds """
-        build = Build.objects.create(**self.default_project_build_success)
+        result = re.findall('bash:clean', cell, re.MULTILINE)
+        self.assertEqual(len(result), 1)
+
+    def test_run_again(self):
+        """
+        "Run again" button should not be shown for command-line builds,
+        but should be shown for other builds
+        """
+        build1 = Build.objects.create(**self.project1_build_success)
+        default_build = Build.objects.create(**self.default_project_build_success)
         url = reverse('all-builds')
         response = self.client.get(url, follow=True)
         soup = BeautifulSoup(response.content)
 
-        attrs = {'data-latest-build-result': build.id}
-        result = soup.find('div', attrs=attrs)
-
         # shouldn't see a run again button for command-line builds
+        attrs = {'data-latest-build-result': default_build.id}
+        result = soup.find('div', attrs=attrs)
         run_again_button = result.select('button')
         self.assertEqual(len(run_again_button), 0)
 
-        # should see a help icon for command-line builds
-        help_icon = result.select('i.get-help-green')
-        self.assertEqual(len(help_icon), 1)
+        # should see a run again button for non-command-line builds
+        attrs = {'data-latest-build-result': build1.id}
+        result = soup.find('div', attrs=attrs)
+        run_again_button = result.select('button')
+        self.assertEqual(len(run_again_button), 1)
 
     def test_tooltips_on_project_name(self):
         """
@@ -989,20 +1066,28 @@ class AllBuildsPageTests(TestCase):
         default_build = Build.objects.create(**self.default_project_build_success)
 
         url = reverse('all-builds')
-        response = self.client.get(url, follow=True)
-        soup = BeautifulSoup(response.content)
+        response = self.client.get(url, {'format': 'json'}, follow=True)
+        data = json.loads(response.content)
+
+        # get the data row for the non-command-line builds project
+        other_project_row = self._get_row_for_build(data, build1.id)
+
+        # make sure there is some HTML
+        soup = BeautifulSoup(other_project_row['static:project-name'])
+        self.assertEqual(len(soup.select('a')), 1,
+                         'should be a project name link')
 
         # no help icon on non-default project name
-        result = soup.find('tr', attrs={'data-table-build-result': build1.id})
-        name = result.select('td.project-name')[0]
-        icons = name.select('i.get-help')
+        icons = soup.select('i.get-help')
         self.assertEqual(len(icons), 0,
                          'should not be a help icon for non-cli builds name')
 
+        # get the data row for the command-line builds project
+        default_project_row = self._get_row_for_build(data, default_build.id)
+
         # help icon on default project name
-        result = soup.find('tr', attrs={'data-table-build-result': default_build.id})
-        name = result.select('td.project-name')[0]
-        icons = name.select('i.get-help')
+        soup = BeautifulSoup(default_project_row['static:project-name'])
+        icons = soup.select('i.get-help')
         self.assertEqual(len(icons), 1,
                          'should be a help icon for cli builds name')
 
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 15/17] toaster: toastergui Don't hide all elements with .col class
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (13 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 14/17] toaster: tests Fix Django tests for new ToasterTable pages Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 16/17] toaster: toastergui Ensure filter_value updates Elliot Smith
                   ` (2 subsequent siblings)
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Disabling the "project" column in a ToasterTable for builds
causes the recent builds area to be hidden. This is because
the column hiding code hides all elements with a class matching
".<column>", regardless of where they occur on the page; and
the recent builds area was using the ".project-name" class,
which means it is included in the set of elements which are hidden.

Scope the element search to the table so that only elements
within the table are hidden or shown.

[YOCTO #8792]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/static/js/table.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js
index d77ebaf..5ffb319 100644
--- a/bitbake/lib/toaster/toastergui/static/js/table.js
+++ b/bitbake/lib/toaster/toastergui/static/js/table.js
@@ -313,7 +313,7 @@ function tableInit(ctx){
       $("#"+ctx.tableName+" th").each(function(){
         for (var i in cols_hidden){
           if ($(this).hasClass(cols_hidden[i])){
-            $("."+cols_hidden[i]).hide();
+            table.find("."+cols_hidden[i]).hide();
             $("#checkbox-"+cols_hidden[i]).removeAttr("checked");
           }
         }
@@ -323,7 +323,7 @@ function tableInit(ctx){
          * user setting.
          */
         for (var i in defaultHiddenCols) {
-          $("."+defaultHiddenCols[i]).hide();
+          table.find("."+defaultHiddenCols[i]).hide();
           $("#checkbox-"+defaultHiddenCols[i]).removeAttr("checked");
         }
     }
@@ -367,9 +367,9 @@ function tableInit(ctx){
     var disabled_cols = [];
 
     if ($(this).prop("checked")) {
-      $("."+col).show();
+      table.find("."+col).show();
     }  else {
-      $("."+col).hide();
+      table.find("."+col).hide();
       /* If we're ordered by the column we're hiding remove the order by */
       if (col === tableParams.orderby ||
           '-' + col === tableParams.orderby){
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 16/17] toaster: toastergui Ensure filter_value updates
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (14 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 15/17] toaster: toastergui Don't hide all elements with .col class Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18  9:23 ` [review-request][PATCH 17/17] toaster: toastergui Streamline construction of filter objects Elliot Smith
  2015-12-18 11:03 ` [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Smith, Elliot
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

Clicking on the radio button for a date range filter action
populates the from and to fields for that action if they are empty.

However, because this doesn't fire "change" events, clicking on
the radio button doesn't update the filter_value hidden field. This
means that the date range action's filter_value parameter isn't
set correctly when the filter popup is submitted.

Manually call the changeHandler() to set the filter_value whenever
the radio for a date range filter is clicked.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/static/js/table.js | 32 +++++++++++++----------
 1 file changed, 18 insertions(+), 14 deletions(-)

diff --git a/bitbake/lib/toaster/toastergui/static/js/table.js b/bitbake/lib/toaster/toastergui/static/js/table.js
index 5ffb319..0c356a3 100644
--- a/bitbake/lib/toaster/toastergui/static/js/table.js
+++ b/bitbake/lib/toaster/toastergui/static/js/table.js
@@ -485,20 +485,6 @@ function tableInit(ctx){
       action.find('[data-date-to-for]').datepicker(options);
     inputTo.val(selectedTo);
 
-    // if the radio button is checked and one or both of the datepickers are
-    // empty, populate them with today's date
-    radio.change(function () {
-      var now = new Date();
-
-      if (inputFrom.val() === '') {
-        inputFrom.datepicker('setDate', now);
-      }
-
-      if (inputTo.val() === '') {
-        inputTo.datepicker('setDate', now);
-      }
-    });
-
     // set filter_value based on date pickers when
     // one of their values changes
     var changeHandler = function () {
@@ -526,6 +512,24 @@ function tableInit(ctx){
       inputFrom.datepicker('option', 'maxDate', inputTo.val());
     });
 
+    // if the radio button is checked and one or both of the datepickers are
+    // empty, populate them with today's date
+    radio.change(function () {
+      var now = new Date();
+
+      if (inputFrom.val() === '') {
+        inputFrom.datepicker('setDate', now);
+      }
+
+      if (inputTo.val() === '') {
+        inputTo.datepicker('setDate', now);
+      }
+
+      // setting the date like this doesn't fire the changeHandler to
+      // update the filter_value, so do that manually instead
+      changeHandler()
+    });
+
     return action;
   }
 
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [review-request][PATCH 17/17] toaster: toastergui Streamline construction of filter objects
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (15 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 16/17] toaster: toastergui Ensure filter_value updates Elliot Smith
@ 2015-12-18  9:23 ` Elliot Smith
  2015-12-18 11:03 ` [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Smith, Elliot
  17 siblings, 0 replies; 19+ messages in thread
From: Elliot Smith @ 2015-12-18  9:23 UTC (permalink / raw)
  To: toaster

In line with comments from review, remove the QuerysetFilter
class (redundant) and convert ProjectFilters into a class
with static methods.

[YOCTO #8738]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/toastergui/querysetfilter.py | 24 ---------------
 bitbake/lib/toaster/toastergui/tablefilter.py    | 38 ++++++++++--------------
 bitbake/lib/toaster/toastergui/tables.py         | 35 +++++++++++-----------
 bitbake/lib/toaster/toastergui/widgets.py        |  1 -
 4 files changed, 32 insertions(+), 66 deletions(-)
 delete mode 100644 bitbake/lib/toaster/toastergui/querysetfilter.py

diff --git a/bitbake/lib/toaster/toastergui/querysetfilter.py b/bitbake/lib/toaster/toastergui/querysetfilter.py
deleted file mode 100644
index 10cc988..0000000
--- a/bitbake/lib/toaster/toastergui/querysetfilter.py
+++ /dev/null
@@ -1,24 +0,0 @@
-class QuerysetFilter(object):
-    """ Filter for a queryset """
-
-    def __init__(self, criteria=None):
-        self.criteria = None
-        if criteria:
-            self.set_criteria(criteria)
-
-    def set_criteria(self, criteria):
-        """
-        criteria is an instance of django.db.models.Q;
-        see https://docs.djangoproject.com/en/1.9/ref/models/querysets/#q-objects
-        """
-        self.criteria = criteria
-
-    def filter(self, queryset):
-        """
-        Filter queryset according to the criteria for this filter,
-        returning the filtered queryset
-        """
-        if self.criteria:
-            return queryset.filter(self.criteria)
-        else:
-            return queryset
diff --git a/bitbake/lib/toaster/toastergui/tablefilter.py b/bitbake/lib/toaster/toastergui/tablefilter.py
index 6cbf975..9dee269 100644
--- a/bitbake/lib/toaster/toastergui/tablefilter.py
+++ b/bitbake/lib/toaster/toastergui/tablefilter.py
@@ -1,7 +1,6 @@
 from datetime import timedelta
 from django.db.models import Q, Max, Min
 from django.utils import dateparse, timezone
-from querysetfilter import QuerysetFilter
 
 class TableFilter(object):
     """
@@ -97,10 +96,10 @@ class TableFilterAction(object):
     ToasterTable
     """
 
-    def __init__(self, name, title, queryset_filter):
+    def __init__(self, name, title, criteria):
         self.name = name
         self.title = title
-        self.queryset_filter = queryset_filter
+        self.criteria = criteria
 
         # set in subclasses
         self.type = None
@@ -111,11 +110,13 @@ class TableFilterAction(object):
         the structure of this string depends on the type of action;
         it's ignored for a toggle filter action, which is just on or off
         """
-        if not params:
-            return
+        pass
 
     def filter(self, queryset):
-        return self.queryset_filter.filter(queryset)
+        if self.criteria:
+            return queryset.filter(self.criteria)
+        else:
+            return queryset
 
     def to_json(self, queryset):
         """ Dump as a JSON object """
@@ -146,16 +147,12 @@ class TableFilterActionDay(TableFilterAction):
     YESTERDAY = 'yesterday'
 
     def __init__(self, name, title, field, day,
-    queryset_filter = QuerysetFilter(), query_helper = TableFilterQueryHelper()):
+    query_helper = TableFilterQueryHelper()):
         """
         field: (string) the datetime field to filter by
         day: (string) "today" or "yesterday"
         """
-        super(TableFilterActionDay, self).__init__(
-            name,
-            title,
-            queryset_filter
-        )
+        super(TableFilterActionDay, self).__init__(name, title, None)
         self.type = 'day'
         self.field = field
         self.day = day
@@ -168,8 +165,6 @@ class TableFilterActionDay(TableFilterAction):
         depending on when the filtering is applied
         """
 
-        criteria = None
-        date_str = None
         now = timezone.now()
 
         if self.day == self.YESTERDAY:
@@ -180,15 +175,13 @@ class TableFilterActionDay(TableFilterAction):
 
         wanted_date_str = wanted_date.strftime('%Y-%m-%d')
 
-        criteria = self.query_helper.dateStringsToQ(
+        self.criteria = self.query_helper.dateStringsToQ(
             self.field,
             wanted_date_str,
             wanted_date_str
         )
 
-        self.queryset_filter.set_criteria(criteria)
-
-        return self.queryset_filter.filter(queryset)
+        return queryset.filter(self.criteria)
 
 class TableFilterActionDateRange(TableFilterAction):
     """
@@ -197,14 +190,14 @@ class TableFilterActionDateRange(TableFilterAction):
     """
 
     def __init__(self, name, title, field,
-    queryset_filter = QuerysetFilter(), query_helper = TableFilterQueryHelper()):
+    query_helper = TableFilterQueryHelper()):
         """
         field: (string) the field to find the max/min range from in the queryset
         """
         super(TableFilterActionDateRange, self).__init__(
             name,
             title,
-            queryset_filter
+            None
         )
 
         self.type = 'daterange'
@@ -227,17 +220,16 @@ class TableFilterActionDateRange(TableFilterAction):
         try:
             date_from_str, date_to_str = params.split(',')
         except ValueError:
-            self.queryset_filter.set_criteria(None)
+            self.criteria = None
             return
 
         # one of the values required for the filter is missing, so set
         # it to the one which was supplied
-        criteria = self.query_helper.dateStringsToQ(
+        self.criteria = self.query_helper.dateStringsToQ(
             self.field,
             date_from_str,
             date_to_str
         )
-        self.queryset_filter.set_criteria(criteria)
 
     def to_json(self, queryset):
         """ Dump as a JSON object """
diff --git a/bitbake/lib/toaster/toastergui/tables.py b/bitbake/lib/toaster/toastergui/tables.py
index 1449acc..ffe3357 100644
--- a/bitbake/lib/toaster/toastergui/tables.py
+++ b/bitbake/lib/toaster/toastergui/tables.py
@@ -20,7 +20,6 @@
 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 
 from toastergui.widgets import ToasterTable
-from toastergui.querysetfilter import QuerysetFilter
 from orm.models import Recipe, ProjectLayer, Layer_Version, Machine, Project
 from orm.models import CustomImageRecipe, Package, Build, LogMessage, Task
 from orm.models import ProjectTarget
@@ -37,9 +36,13 @@ from toastergui.tablefilter import TableFilterActionDateRange
 from toastergui.tablefilter import TableFilterActionDay
 
 class ProjectFilters(object):
-    def __init__(self, project_layers):
-        self.in_project = QuerysetFilter(Q(layer_version__in=project_layers))
-        self.not_in_project = QuerysetFilter(~Q(layer_version__in=project_layers))
+    @staticmethod
+    def in_project(project_layers):
+        return Q(layer_version__in=project_layers)
+
+    @staticmethod
+    def not_in_project(project_layers):
+        return ~(ProjectFilters.in_project(project_layers))
 
 class LayersTable(ToasterTable):
     """Table of layers in Toaster"""
@@ -71,13 +74,13 @@ class LayersTable(ToasterTable):
         in_project_action = TableFilterActionToggle(
             "in_project",
             "Layers added to this project",
-            QuerysetFilter(criteria)
+            criteria
         )
 
         not_in_project_action = TableFilterActionToggle(
             "not_in_project",
             "Layers not added to this project",
-            QuerysetFilter(~criteria)
+            ~criteria
         )
 
         in_current_project_filter.add_action(in_project_action)
@@ -217,8 +220,6 @@ class MachinesTable(ToasterTable):
     def setup_filters(self, *args, **kwargs):
         project = Project.objects.get(pk=kwargs['pid'])
 
-        project_filters = ProjectFilters(self.project_layers)
-
         in_current_project_filter = TableFilter(
             "in_current_project",
             "Filter by project machines"
@@ -227,13 +228,13 @@ class MachinesTable(ToasterTable):
         in_project_action = TableFilterActionToggle(
             "in_project",
             "Machines provided by layers added to this project",
-            project_filters.in_project
+            ProjectFilters.in_project(self.project_layers)
         )
 
         not_in_project_action = TableFilterActionToggle(
             "not_in_project",
             "Machines provided by layers not added to this project",
-            project_filters.not_in_project
+            ProjectFilters.not_in_project(self.project_layers)
         )
 
         in_current_project_filter.add_action(in_project_action)
@@ -350,8 +351,6 @@ class RecipesTable(ToasterTable):
         return context
 
     def setup_filters(self, *args, **kwargs):
-        project_filters = ProjectFilters(self.project_layers)
-
         table_filter = TableFilter(
             'in_current_project',
             'Filter by project recipes'
@@ -360,13 +359,13 @@ class RecipesTable(ToasterTable):
         in_project_action = TableFilterActionToggle(
             'in_project',
             'Recipes provided by layers added to this project',
-            project_filters.in_project
+            ProjectFilters.in_project(self.project_layers)
         )
 
         not_in_project_action = TableFilterActionToggle(
             'not_in_project',
             'Recipes provided by layers not added to this project',
-            project_filters.not_in_project
+            ProjectFilters.not_in_project(self.project_layers)
         )
 
         table_filter.add_action(in_project_action)
@@ -1141,13 +1140,13 @@ class BuildsTable(ToasterTable):
         successful_builds_action = TableFilterActionToggle(
             'successful_builds',
             'Successful builds',
-            QuerysetFilter(Q(outcome=Build.SUCCEEDED))
+            Q(outcome=Build.SUCCEEDED)
         )
 
         failed_builds_action = TableFilterActionToggle(
             'failed_builds',
             'Failed builds',
-            QuerysetFilter(Q(outcome=Build.FAILED))
+            Q(outcome=Build.FAILED)
         )
 
         outcome_filter.add_action(successful_builds_action)
@@ -1227,13 +1226,13 @@ class BuildsTable(ToasterTable):
         with_failed_tasks_action = TableFilterActionToggle(
             'with_failed_tasks',
             'Builds with failed tasks',
-            QuerysetFilter(criteria)
+            criteria
         )
 
         without_failed_tasks_action = TableFilterActionToggle(
             'without_failed_tasks',
             'Builds without failed tasks',
-            QuerysetFilter(~criteria)
+            ~criteria
         )
 
         failed_tasks_filter.add_action(with_failed_tasks_action)
diff --git a/bitbake/lib/toaster/toastergui/widgets.py b/bitbake/lib/toaster/toastergui/widgets.py
index bc081b8..d9328d4 100644
--- a/bitbake/lib/toaster/toastergui/widgets.py
+++ b/bitbake/lib/toaster/toastergui/widgets.py
@@ -32,7 +32,6 @@ from django.template import Context, Template
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.exceptions import FieldError
 from django.conf.urls import url, patterns
-from toastergui.querysetfilter import QuerysetFilter
 
 import types
 import json
-- 
Elliot Smith
Software Engineer
Intel OTC

---------------------------------------------------------------------
Intel Corporation (UK) Limited
Registered No. 1134945 (England)
Registered Office: Pipers Way, Swindon SN3 1RJ
VAT No: 860 2173 47

This e-mail and any attachments may contain confidential material for
the sole use of the intended recipient(s). Any review or distribution
by others is strictly prohibited. If you are not the intended
recipient, please contact the sender and delete all copies.



^ permalink raw reply related	[flat|nested] 19+ messages in thread

* Re: [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable
  2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
                   ` (16 preceding siblings ...)
  2015-12-18  9:23 ` [review-request][PATCH 17/17] toaster: toastergui Streamline construction of filter objects Elliot Smith
@ 2015-12-18 11:03 ` Smith, Elliot
  17 siblings, 0 replies; 19+ messages in thread
From: Smith, Elliot @ 2015-12-18 11:03 UTC (permalink / raw)
  To: toaster

[-- Attachment #1: Type: text/plain, Size: 4690 bytes --]

On 18 December 2015 at 09:23, Elliot Smith <elliot.smith@intel.com> wrote:

> Convert some of the most important Toaster pages to ToasterTable rather
> than
> individual templates.
>

I just realised two things I missed:

1. I added a new Python file, but neglected to add the licence header.
2. I left the old (commented-out) view code in views.py.

I'll fix (1) in the branch, but I'll leave (2) until after review, in case
there's some code I need to port and have missed.

Elliot


>
> This involved a rewrite of the ToasterTable filter system (see
> tablefilter.py)
> to cope with date range filtering for the builds tables.
>
> I also moved a lot of code out of the views into the model, where it seems
> better placed.
>
> I also fixed a few JS issues which arose while testing, such as filters
> not de-selecting when there are multiple filters on a table; event handlers
> being lost when repopulating a table; and incorrect hiding of page elements
> due to table classes being used for the page title element.
>
> Related bug: https://bugzilla.yoctoproject.org/show_bug.cgi?id=8738
>
> Also incidentally fixes
> https://bugzilla.yoctoproject.org/show_bug.cgi?id=8792,
> as this functionality works correctly in ToasterTable.
>
> Changes since 6d35574 (toaster-next) are in
> git://git.yoctoproject.org/poky-contrib, elliot/toaster/tables-8738
>
> http://git.yoctoproject.org/cgit.cgi/poky-contrib/log/?h=elliot/toaster/tables-8738
>
> Elliot Smith (17):
>   toaster: toastergui Use ToasterTable for projects page
>   toaster: Move image file suffix list to model
>   toaster: Check inferred file suffixes against list of known types
>   toaster: toastergui Switch projects/ view to ToasterTable
>   toaster: toastergui Use event delegates for hover help elements
>   toaster: toastergui Convert all builds page to ToasterTable
>   toaster: toastergui Add QuerysetFilter class
>   toaster: toastergui Refactor ToasterTable filtering
>   toaster: toastergui Switch off filter highlights when inactive
>   toaster: toastergui Show recent builds on all builds page
>   toaster: toastergui Implement date range filters for builds
>   toaster: toastergui Implement "today" and "yesterday" filters
>   toaster: toastergui Convert project builds page to ToasterTable
>   toaster: tests Fix Django tests for new ToasterTable pages
>   toaster: toastergui Don't hide all elements with .col class
>   toaster: toastergui Ensure filter_value updates
>   toaster: toastergui Streamline construction of filter objects
>
>  bitbake/lib/toaster/orm/models.py                  | 109 ++-
>  .../lib/toaster/toastergui/static/js/libtoaster.js |   6 +-
>  .../toaster/toastergui/static/js/projecttopbar.js  |   9 +
>  bitbake/lib/toaster/toastergui/static/js/table.js  | 272 +++++--
>  bitbake/lib/toaster/toastergui/tablefilter.py      | 271 +++++++
>  bitbake/lib/toaster/toastergui/tables.py           | 826
> +++++++++++++++++++--
>  .../toastergui/templates/baseprojectpage.html      |   1 +
>  .../toastergui/templates/builds-toastertable.html  |  48 ++
>  .../toaster/toastergui/templates/mrb_section.html  |   4 +-
>  .../templates/projectbuilds-toastertable.html      |  56 ++
>  .../templates/projects-toastertable.html           |  36 +
>  .../toaster/toastergui/templates/toastertable.html |   7 +-
>  bitbake/lib/toaster/toastergui/tests.py            | 255 ++++---
>  bitbake/lib/toaster/toastergui/urls.py             |  14 +-
>  bitbake/lib/toaster/toastergui/views.py            |  79 +-
>  bitbake/lib/toaster/toastergui/widgets.py          |  94 ++-
>  16 files changed, 1781 insertions(+), 306 deletions(-)
>  create mode 100644 bitbake/lib/toaster/toastergui/tablefilter.py
>  create mode 100644
> bitbake/lib/toaster/toastergui/templates/builds-toastertable.html
>  create mode 100644
> bitbake/lib/toaster/toastergui/templates/projectbuilds-toastertable.html
>  create mode 100644
> bitbake/lib/toaster/toastergui/templates/projects-toastertable.html
>
> --
> Elliot Smith
> Software Engineer
> Intel OTC
>
> ---------------------------------------------------------------------
> Intel Corporation (UK) Limited
> Registered No. 1134945 (England)
> Registered Office: Pipers Way, Swindon SN3 1RJ
> VAT No: 860 2173 47
>
> This e-mail and any attachments may contain confidential material for
> the sole use of the intended recipient(s). Any review or distribution
> by others is strictly prohibited. If you are not the intended
> recipient, please contact the sender and delete all copies.
>
>


-- 
Elliot Smith
Software Engineer
Intel Open Source Technology Centre

[-- Attachment #2: Type: text/html, Size: 6138 bytes --]

^ permalink raw reply	[flat|nested] 19+ messages in thread

end of thread, other threads:[~2015-12-18 11:03 UTC | newest]

Thread overview: 19+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2015-12-18  9:23 [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 01/17] toaster: toastergui Use ToasterTable for projects page Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 02/17] toaster: Move image file suffix list to model Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 03/17] toaster: Check inferred file suffixes against list of known types Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 04/17] toaster: toastergui Switch projects/ view to ToasterTable Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 05/17] toaster: toastergui Use event delegates for hover help elements Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 06/17] toaster: toastergui Convert all builds page to ToasterTable Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 07/17] toaster: toastergui Add QuerysetFilter class Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 08/17] toaster: toastergui Refactor ToasterTable filtering Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 09/17] toaster: toastergui Switch off filter highlights when inactive Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 10/17] toaster: toastergui Show recent builds on all builds page Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 11/17] toaster: toastergui Implement date range filters for builds Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 12/17] toaster: toastergui Implement "today" and "yesterday" filters Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 13/17] toaster: toastergui Convert project builds page to ToasterTable Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 14/17] toaster: tests Fix Django tests for new ToasterTable pages Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 15/17] toaster: toastergui Don't hide all elements with .col class Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 16/17] toaster: toastergui Ensure filter_value updates Elliot Smith
2015-12-18  9:23 ` [review-request][PATCH 17/17] toaster: toastergui Streamline construction of filter objects Elliot Smith
2015-12-18 11:03 ` [review-request][PATCH 00/17] Convert builds/, projects/ and projectbuilds/ to ToasterTable Smith, Elliot

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.