All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH 0/3] Add buttons to build dashboard to customise/edit images
@ 2016-04-11 14:56 Elliot Smith
  2016-04-11 14:56 ` [PATCH 1/3] toaster: add build dashboard buttons to edit/create custom images Elliot Smith
                   ` (2 more replies)
  0 siblings, 3 replies; 7+ messages in thread
From: Elliot Smith @ 2016-04-11 14:56 UTC (permalink / raw)
  To: toaster

Add buttons to the build dashboard which enable a user to customise an image
built during that build, or to create a custom image from an image built
during that build.

To test:

1. Create a project.
2. Build an image like core-image-minimal.
3. When the build completes, go to its build dashboard. Click the button in
the left navigation which creates a custom image from the built image.
4. Run a build like "core-image-minimal core-image-sato".
5. In the build dashboard again, click on the button to create a custom image
from the images built during that build. You should see a dialog with radio
buttons allowing you to choose which image to customise.
6. Create a custom image.
7. Run a build of that custom image.
8. When the build completes, view its build dashboard. It should have a button
which links to the custom image edit page for the image.
9. Create a second custom image.
10. Run a build which builds both of your custom images.
11. When the build completes, view its build dashboard. It should have a button
which, when clicked, gives a choice of the two custom images for editing.

Repeat the above with any combinations of the 4 images you've already built.
Note that the UI tests cover the above combinations.

The following changes since commit 4bb6fb30b0d660eaeaf4af134b99b2feaf0b3db2:

  toaster: fixes for customimage package not found (2016-04-08 09:38:50 +0100)

are available in the git repository at:

  git://git.yoctoproject.org/poky-contrib elliot/toaster/build_history_ic_links_with_tests-9123
  http://git.yoctoproject.org/cgit.cgi/poky-contrib/log/?h=elliot/toaster/build_history_ic_links_with_tests-9123

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

Elliot Smith (3):
  toaster: add build dashboard buttons to edit/create custom images
  toaster: add modal to select custom image for editing
  toaster-tests: tests for build dashboard

 bitbake/lib/toaster/orm/models.py                  |  48 ++++
 .../tests/browser/test_builddashboard_page.py      | 251 +++++++++++++++++++++
 .../lib/toaster/toastergui/static/js/layerBtn.js   |   3 +-
 .../lib/toaster/toastergui/static/js/libtoaster.js |   2 +
 .../toastergui/static/js/newcustomimage_modal.js   |  98 +++++++-
 .../toaster/toastergui/static/js/recipedetails.js  |   3 +-
 bitbake/lib/toaster/toastergui/templates/base.html |   1 -
 .../toastergui/templates/basebuildpage.html        | 213 +++++++++++------
 .../templates/editcustomimage_modal.html           |  71 ++++++
 .../toastergui/templates/newcustomimage_modal.html |  28 ++-
 .../templatetags/objects_to_dictionaries_filter.py |  35 +++
 bitbake/lib/toaster/toastergui/tests.py            |  87 -------
 bitbake/lib/toaster/toastergui/views.py            |  33 ++-
 13 files changed, 691 insertions(+), 182 deletions(-)
 create mode 100644 bitbake/lib/toaster/tests/browser/test_builddashboard_page.py
 create mode 100644 bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
 create mode 100644 bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py

--
1.9.3

---------------------------------------------------------------------
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] 7+ messages in thread

* [PATCH 1/3] toaster: add build dashboard buttons to edit/create custom images
  2016-04-11 14:56 [PATCH 0/3] Add buttons to build dashboard to customise/edit images Elliot Smith
@ 2016-04-11 14:56 ` Elliot Smith
  2016-04-19 16:33   ` Michael Wood
  2016-04-11 14:56 ` [PATCH 2/3] toaster: add modal to select custom image for editing Elliot Smith
  2016-04-11 14:56 ` [PATCH 3/3] toaster-tests: tests for build dashboard Elliot Smith
  2 siblings, 1 reply; 7+ messages in thread
From: Elliot Smith @ 2016-04-11 14:56 UTC (permalink / raw)
  To: toaster

When a build is viewed in the dashboard, enable users to edit
a custom image which was built during that build, and/or create
a new custom image based on one of the image recipes built during
the build.

Add methods to the Build model to enable querying for the
set of image recipes built during a build.

Add buttons to the dashboard, with the "Edit custom image"
button opening a basic modal for now. The "New custom image"
button opens the existing new custom image modal, but is modified
to show a list of images available as a base for a new custom image.

Add a new function to the new custom image modal's script which
enables multiple potential custom images to be shown as radio
buttons in the dialog (if there is more than 1). Modify existing
code to use this new function.

Add a template filter which allows the queryset of recipes for
a build to be available to client-side scripts, and from there
be used to populate the new custom image modal.

[YOCTO #9123]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/orm/models.py                  |  41 ++++
 .../lib/toaster/toastergui/static/js/layerBtn.js   |   3 +-
 .../toastergui/static/js/newcustomimage_modal.js   |  97 +++++++++-
 .../toaster/toastergui/static/js/recipedetails.js  |   3 +-
 .../toastergui/templates/basebuildpage.html        | 207 +++++++++++++--------
 .../templates/editcustomimage_modal.html           |  23 +++
 .../toastergui/templates/newcustomimage_modal.html |  28 ++-
 .../templatetags/queryset_to_list_filter.py        |  26 +++
 bitbake/lib/toaster/toastergui/views.py            |   7 +-
 9 files changed, 344 insertions(+), 91 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
 create mode 100644 bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py

diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py
index 68c3072..c63d631 100644
--- a/bitbake/lib/toaster/orm/models.py
+++ b/bitbake/lib/toaster/orm/models.py
@@ -484,6 +484,47 @@ class Build(models.Model):
         tgts = Target.objects.filter(build_id = self.id).order_by( 'target' );
         return( tgts );
 
+    def get_recipes(self):
+        """
+        Get the recipes related to this build;
+        note that the related layer versions and layers are also prefetched
+        by this query, as this queryset can be sorted by these objects in the
+        build recipes view; prefetching them here removes the need
+        for another query in that view
+        """
+        layer_versions = Layer_Version.objects.filter(build=self)
+        criteria = Q(layer_version__id__in=layer_versions)
+        return Recipe.objects.filter(criteria) \
+                             .select_related('layer_version', 'layer_version__layer')
+
+    def get_custom_image_recipe_names(self):
+        """
+        Get the names of custom image recipes for this build's project
+        as a list; this is used to screen out custom image recipes from the
+        recipes for the build by name, and to distinguish image recipes from
+        custom image recipes
+        """
+        custom_image_recipes = \
+            CustomImageRecipe.objects.filter(project=self.project)
+        return custom_image_recipes.values_list('name', flat=True)
+
+    def get_image_recipes(self):
+        """
+        Returns a queryset of image recipes related to this build, sorted
+        by name
+        """
+        criteria = Q(is_image=True)
+        return self.get_recipes().filter(criteria).order_by('name')
+
+    def get_custom_image_recipes(self):
+        """
+        Returns a queryset of custom image recipes related to this build,
+        sorted by name
+        """
+        custom_image_recipe_names = self.get_custom_image_recipe_names()
+        criteria = Q(is_image=True) & Q(name__in=custom_image_recipe_names)
+        return self.get_recipes().filter(criteria).order_by('name')
+
     def get_outcome_text(self):
         return Build.BUILD_OUTCOME[int(self.outcome)][1]
 
diff --git a/bitbake/lib/toaster/toastergui/static/js/layerBtn.js b/bitbake/lib/toaster/toastergui/static/js/layerBtn.js
index aa43284..259271d 100644
--- a/bitbake/lib/toaster/toastergui/static/js/layerBtn.js
+++ b/bitbake/lib/toaster/toastergui/static/js/layerBtn.js
@@ -76,7 +76,8 @@ function layerBtnsInit() {
     if (imgCustomModal.length == 0)
       throw("Modal new-custom-image not found");
 
-    imgCustomModal.data('recipe', $(this).data('recipe'));
+    var recipe = {id: $(this).data('recipe'), name: null}
+    newCustomImageModalSetRecipes([recipe]);
     imgCustomModal.modal('show');
   });
 }
diff --git a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
index 328997a..1ae0d34 100644
--- a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
+++ b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
@@ -1,29 +1,59 @@
 "use strict";
 
-/* Used for the newcustomimage_modal actions */
+/*
+Used for the newcustomimage_modal actions
+
+The .data('recipe') value on the outer element determines which
+recipe ID is used as the basis for the new custom image recipe created via
+this modal.
+
+Use newCustomImageModalSetRecipes() to set the recipes available as a base
+for the new custom image. This will manage the addition of radio buttons
+to select the base image (or remove the radio buttons, if there is only a
+single base image available).
+*/
 function newCustomImageModalInit(){
 
   var newCustomImgBtn = $("#create-new-custom-image-btn");
   var imgCustomModal = $("#new-custom-image-modal");
   var invalidNameHelp = $("#invalid-name-help");
+  var invalidRecipeHelp = $("#invalid-recipe-help");
   var nameInput = imgCustomModal.find('input');
 
-  var invalidMsg = "Image names cannot contain spaces or capital letters. The only allowed special character is dash (-).";
+  var invalidNameMsg = "Image names cannot contain spaces or capital letters. The only allowed special character is dash (-).";
+  var duplicateNameMsg = "An image with this name already exists. Image names must be unique.";
+  var invalidBaseRecipeIdMsg = "Please select an image to customise.";
+
+  // capture clicks on radio buttons inside the modal; when one is selected,
+  // set the recipe on the modal
+  imgCustomModal.on("click", "[name='select-image']", function (e) {
+    clearRecipeError();
+
+    var recipeId = $(e.target).attr('data-recipe');
+    imgCustomModal.data('recipe', recipeId);
+  });
 
   newCustomImgBtn.click(function(e){
     e.preventDefault();
 
     var baseRecipeId = imgCustomModal.data('recipe');
 
+    if (!baseRecipeId) {
+      showRecipeError(invalidBaseRecipeIdMsg);
+      return;
+    }
+
     if (nameInput.val().length > 0) {
       libtoaster.createCustomRecipe(nameInput.val(), baseRecipeId,
       function(ret) {
         if (ret.error !== "ok") {
           console.warn(ret.error);
           if (ret.error === "invalid-name") {
-            showError(invalidMsg);
+            showNameError(invalidNameMsg);
+            return;
           } else if (ret.error === "already-exists") {
-            showError("An image with this name already exists. Image names must be unique.");
+            showNameError(duplicateNameMsg);
+            return;
           }
         } else {
           imgCustomModal.modal('hide');
@@ -33,12 +63,21 @@ function newCustomImageModalInit(){
     }
   });
 
-  function showError(text){
+  function showNameError(text){
     invalidNameHelp.text(text);
     invalidNameHelp.show();
     nameInput.parent().addClass('error');
   }
 
+  function showRecipeError(text){
+    invalidRecipeHelp.text(text);
+    invalidRecipeHelp.show();
+  }
+
+  function clearRecipeError(){
+    invalidRecipeHelp.hide();
+  }
+
   nameInput.on('keyup', function(){
     if (nameInput.val().length === 0){
       newCustomImgBtn.prop("disabled", true);
@@ -46,7 +85,7 @@ function newCustomImageModalInit(){
     }
 
     if (nameInput.val().search(/[^a-z|0-9|-]/) != -1){
-      showError(invalidMsg);
+      showNameError(invalidNameMsg);
       newCustomImgBtn.prop("disabled", true);
       nameInput.parent().addClass('error');
     } else {
@@ -56,3 +95,49 @@ function newCustomImageModalInit(){
     }
   });
 }
+
+// Set the image recipes which can used as the basis for the custom
+// image recipe the user is creating
+//
+// baseRecipes: a list of one or more recipes which can be
+// used as the base for the new custom image recipe in the format:
+// [{'id': <recipe ID>, 'name': <recipe name>'}, ...]
+//
+// if recipes is a single recipe, just show the text box to set the
+// name for the new custom image; if recipes contains multiple recipe objects,
+// show a set of radio buttons so the user can decide which to use as the
+// basis for the new custom image
+function newCustomImageModalSetRecipes(baseRecipes) {
+  var imgCustomModal = $("#new-custom-image-modal");
+  var imageSelector = $('#new-custom-image-modal [data-role="image-selector"]');
+  var imageSelectRadiosContainer = $('#new-custom-image-modal [data-role="image-selector-radios"]');
+
+  if (baseRecipes.length === 1) {
+    // hide the radio button container
+    imageSelector.hide();
+
+    // remove any radio buttons + labels
+    imageSelector.remove('[data-role="image-radio"]');
+
+    // set the single recipe ID on the modal as it's the only one
+    // we can build from
+    imgCustomModal.data('recipe', baseRecipes[0].id);
+  }
+  else {
+    // add radio buttons; note that the handlers for the radio buttons
+    // are set in newCustomImageModalInit via event delegation
+    for (var i = 0; i < baseRecipes.length; i++) {
+      var recipe = baseRecipes[i];
+      imageSelectRadiosContainer.append(
+        '<label class="radio" data-role="image-radio">' +
+        recipe.name +
+        '<input type="radio" class="form-control" name="select-image" ' +
+        'data-recipe="' + recipe.id + '">' +
+        '</label>'
+      );
+    }
+
+    // show the radio button container
+    imageSelector.show();
+  }
+}
diff --git a/bitbake/lib/toaster/toastergui/static/js/recipedetails.js b/bitbake/lib/toaster/toastergui/static/js/recipedetails.js
index d5f9eac..604db5f 100644
--- a/bitbake/lib/toaster/toastergui/static/js/recipedetails.js
+++ b/bitbake/lib/toaster/toastergui/static/js/recipedetails.js
@@ -9,7 +9,8 @@ function recipeDetailsPageInit(ctx){
     if (imgCustomModal.length === 0)
       throw("Modal new-custom-image not found");
 
-    imgCustomModal.data('recipe', $(this).data('recipe'));
+    var recipe = {id: $(this).data('recipe'), name: null}
+    newCustomImageModalSetRecipes([recipe]);
     imgCustomModal.modal('show');
   });
 
diff --git a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
index ff9433e..4a8e2a7 100644
--- a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
+++ b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
@@ -1,90 +1,149 @@
 {% extends "base.html" %}
 {% load projecttags %}
 {% load project_url_tag %}
+{% load queryset_to_list_filter %}
 {% load humanize %}
 {% block pagecontent %}
+  <!-- breadcrumbs -->
+  <div class="section">
+    <ul class="breadcrumb" id="breadcrumb">
+      <li><a href="{% project_url build.project %}">{{build.project.name}}</a></li>
+      {% if not build.project.is_default %}
+        <li><a href="{% url 'projectbuilds' build.project.id %}">Builds</a></li>
+      {% endif %}
+      <li>
+        {% block parentbreadcrumb %}
+          <a href="{%url 'builddashboard' build.pk%}">
+            {{build.get_sorted_target_list.0.target}} {% if build.target_set.all.count > 1 %}(+{{build.target_set.all.count|add:"-1"}}){% endif %} {{build.machine}} ({{build.completed_on|date:"d/m/y H:i"}})
+          </a>
+        {% endblock %}
+      </li>
+      {% block localbreadcrumb %}{% endblock %}
+    </ul>
+    <script>
+      $( function () {
+        $('#breadcrumb > li').append('<span class="divider">&rarr;</span>');
+        $('#breadcrumb > li:last').addClass("active");
+        $('#breadcrumb > li:last > span').remove();
+      });
+    </script>
+  </div>
+
+  <div class="row-fluid">
+    <!-- begin left sidebar container -->
+    <div id="nav" class="span2">
+      <ul class="nav nav-list well">
+        <li
+          {% if request.resolver_match.url_name == 'builddashboard'  %}
+            class="active"
+          {% endif %} >
+          <a class="nav-parent" href="{% url 'builddashboard' build.pk %}">Build summary</a>
+        </li>
+        {% if build.target_set.all.0.is_image and build.outcome == 0 %}
+          <li class="nav-header">Images</li>
+          {% block nav-target %}
+            {% for t in build.get_sorted_target_list %}
+              <li><a href="{% url 'target' build.pk t.pk %}">{{t.target}}</a><li>
+            {% endfor %}
+          {% endblock %}
+        {% endif %}
+        <li class="nav-header">Build</li>
+        {% block nav-configuration %}
+          <li><a href="{% url 'configuration' build.pk %}">Configuration</a></li>
+        {% endblock %}
+        {% block nav-tasks %}
+          <li><a href="{% url 'tasks' build.pk %}">Tasks</a></li>
+        {% endblock %}
+        {% block nav-recipes %}
+          <li><a href="{% url 'recipes' build.pk %}">Recipes</a></li>
+        {% endblock %}
+        {% block nav-packages %}
+          <li><a href="{% url 'packages' build.pk %}">Packages</a></li>
+        {% endblock %}
+          <li class="nav-header">Performance</li>
+        {% block nav-buildtime %}
+          <li><a href="{% url 'buildtime' build.pk %}">Time</a></li>
+        {% endblock %}
+        {% block nav-cputime %}
+          <li><a href="{% url 'cputime' build.pk %}">CPU usage</a></li>
+        {% endblock %}
+        {% block nav-diskio %}
+          <li><a href="{% url 'diskio' build.pk %}">Disk I/O</a></li>
+        {% endblock %}
 
+        <li class="divider"></li>
 
- <div class="">
-<!-- Breadcrumbs -->
-    <div class="section">
-        <ul class="breadcrumb" id="breadcrumb">
-            <li><a href="{% project_url build.project %}">{{build.project.name}}</a></li>
-            {% if not build.project.is_default %}
-                <li><a href="{% url 'projectbuilds' build.project.id %}">Builds</a></li>
-            {% endif %}
-            <li>
-            {% block parentbreadcrumb %}
-            <a href="{%url 'builddashboard' build.pk%}">
-              {{build.get_sorted_target_list.0.target}} {%if build.target_set.all.count > 1%}(+{{build.target_set.all.count|add:"-1"}}){%endif%} {{build.machine}} ({{build.completed_on|date:"d/m/y H:i"}})
+        <li>
+          <p class="navbar-btn">
+            <a class="btn btn-block" href="{% url 'build_artifact' build.id 'cookerlog' build.id %}">
+              Download build log
             </a>
-            {% endblock %}
-            </li>
-            {% block localbreadcrumb %}{% endblock %}
-        </ul>
-        <script>
-        $( function () {
-            $('#breadcrumb > li').append('<span class="divider">&rarr;</span>');
-            $('#breadcrumb > li:last').addClass("active");
-            $('#breadcrumb > li:last > span').remove();
-        });
-        </script>
-    </div>
+          </p>
+        </li>
 
-    <div class="row-fluid">
+        <li>
+          <!-- edit custom image built during this build -->
+          <p class="navbar-btn" data-role="edit-custom-image-trigger">
+            <button class="btn btn-block">Edit custom image</button>
+          </p>
+          {% include 'editcustomimage_modal.html' %}
+          <script>
+            $(document).ready(function () {
+              var editableCustomImageRecipes = {{ build.get_custom_image_recipes | queryset_to_list:"id,name" | json }};
 
-        <!-- begin left sidebar container -->
-        <div id="nav" class="span2">
-            <ul class="nav nav-list well">
-              <li
-                {% if request.resolver_match.url_name == 'builddashboard'  %}
-                  class="active"
-                {% endif %} >
-                <a class="nav-parent" href="{% url 'builddashboard' build.pk %}">Build summary</a>
-              </li>
-              {% if build.target_set.all.0.is_image and build.outcome == 0 %}
-                <li class="nav-header">Images</li>
-                {% block nav-target %}
-                  {% for t in build.get_sorted_target_list %}
-                    <li><a href="{% url 'target' build.pk t.pk %}">{{t.target}}</a><li>
-                  {% endfor %}
-                {% endblock %}
-              {% endif %}
-              <li class="nav-header">Build</li>
-              {% block nav-configuration %}
-                  <li><a href="{% url 'configuration' build.pk %}">Configuration</a></li>
-              {% endblock %}
-              {% block nav-tasks %}
-                  <li><a href="{% url 'tasks' build.pk %}">Tasks</a></li>
-              {% endblock %}
-              {% block nav-recipes %}
-                  <li><a href="{% url 'recipes' build.pk %}">Recipes</a></li>
-              {% endblock %}
-              {% block nav-packages %}
-                  <li><a href="{% url 'packages' build.pk %}">Packages</a></li>
-              {% endblock %}
-                  <li class="nav-header">Performance</li>
-              {% block nav-buildtime %}
-                  <li><a href="{% url 'buildtime' build.pk %}">Time</a></li>
-              {% endblock %}
-              {% block nav-cputime %}
-                  <li><a href="{% url 'cputime' build.pk %}">CPU time</a></li>
-              {% endblock %}
-              {% block nav-diskio %}
-                  <li><a href="{% url 'diskio' build.pk %}">Disk I/O</a></li>
-              {% endblock %}
-            </ul>
-        </div>
-        <!-- end left sidebar container -->
+              // edit custom image which was built during this build
+              var editCustomImageModal = $('#edit-custom-image-modal');
+              var editCustomImageTrigger = $('[data-role="edit-custom-image-trigger"]');
 
-        <!-- Begin right container -->
-        {% block buildinfomain %}{% endblock %}
-        <!-- End right container -->
+              editCustomImageTrigger.click(function () {
+                // if there is a single editable custom image, go direct to the edit
+                // page for it; if there are multiple editable custom images, show
+                // dialog to select one of them for editing
 
+                // single editable custom image
 
-    </div>
-  </div>
+                // multiple editable custom images
+                editCustomImageModal.modal('show');
+              });
+            });
+          </script>
+        </li>
 
+        <li>
+          <!-- new custom image from image recipe in this build -->
+          <p class="navbar-btn" data-role="new-custom-image-trigger">
+            <button class="btn btn-block">New custom image</button>
+          </p>
+          {% include 'newcustomimage_modal.html' %}
+          <script>
+            // imageRecipes includes both custom image recipes and built-in
+            // image recipes, any of which can be used as the basis for a
+            // new custom image
+            var imageRecipes = {{ build.get_image_recipes | queryset_to_list:"id,name" | json }};
 
-{% endblock %}
+            $(document).ready(function () {
+              var newCustomImageModal = $('#new-custom-image-modal');
+              var newCustomImageTrigger = $('[data-role="new-custom-image-trigger"]');
 
+              // show create new custom image modal to select an image built
+              // during this build as the basis for the custom recipe
+              newCustomImageTrigger.click(function () {
+                if (!imageRecipes.length) {
+                  return;
+                }
+                newCustomImageModalSetRecipes(imageRecipes);
+                newCustomImageModal.modal('show');
+              });
+            });
+          </script>
+        </li>
+      </ul>
+
+    </div>
+    <!-- end left sidebar container -->
+
+    <!-- begin right container -->
+    {% block buildinfomain %}{% endblock %}
+    <!-- end right container -->
+  </div>
+{% endblock %}
diff --git a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
new file mode 100644
index 0000000..fd998f6
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
@@ -0,0 +1,23 @@
+<!--
+modal dialog shown on the build dashboard, for editing an existing custom image
+-->
+<div class="modal hide fade in" aria-hidden="false" id="edit-custom-image-modal">
+  <div class="modal-header">
+    <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+    <h3>Select custom image to edit</h3>
+  </div>
+  <div class="modal-body">
+    <div class="row-fluid">
+      <span class="help-block">
+        Explanation of what this modal is for
+      </span>
+    </div>
+    <div class="control-group controls">
+      <input type="text" class="huge" placeholder="input box" required>
+      <span class="help-block error" style="display:none">Error text</span>
+    </div>
+  </div>
+  <div class="modal-footer">
+    <button class="btn btn-primary btn-large" disabled>Action</button>
+  </div>
+</div>
diff --git a/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html b/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html
index b1b5148..caeb302 100644
--- a/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html
+++ b/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html
@@ -15,18 +15,34 @@
 <div class="modal hide fade in" id="new-custom-image-modal" aria-hidden="false">
   <div class="modal-header">
     <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
-    <h3>Name your custom image</h3>
+    <h3>New custom image</h3>
   </div>
+
   <div class="modal-body">
+    <!--
+    this container is visible if there are multiple image recipes which could
+    be used as a basis for the new custom image; radio buttons are added to it
+    via newCustomImageModalSetRecipes() as required
+    -->
+    <div data-role="image-selector" style="display:none;">
+      <h4>Which image do you want to customise?</h4>
+      <div data-role="image-selector-radios"></div>
+      <span class="help-block error" id="invalid-recipe-help" style="display:none"></span>
+      <div class="air"></div>
+    </div>
+
+    <h4>Name your custom image</h4>
+
     <div class="row-fluid">
       <span class="help-block span8">Image names must be unique. They should not contain spaces or capital letters, and the only allowed special character is dash (-).<p></p>
       </span></div>
     <div class="control-group controls">
       <input type="text" class="huge" placeholder="Type the custom image name" required>
-        <span class="help-block error" id="invalid-name-help" style="display:none"></span>
-      </div>
-    </div>
-    <div class="modal-footer">
-      <button id="create-new-custom-image-btn" class="btn btn-primary btn-large" data-original-title="" title="" disabled>Create custom image</button>
+      <span class="help-block error" id="invalid-name-help" style="display:none"></span>
     </div>
+  </div>
+
+  <div class="modal-footer">
+    <button id="create-new-custom-image-btn" class="btn btn-primary btn-large" data-original-title="" title="" disabled>Create custom image</button>
+  </div>
 </div>
diff --git a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py b/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
new file mode 100644
index 0000000..dfc094b
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
@@ -0,0 +1,26 @@
+from django import template
+import json
+
+register = template.Library()
+
+def queryset_to_list(queryset, fields):
+    """
+    Convert a queryset to a list; fields can be set to a comma-separated
+    string of fields for each record included in the resulting list; if
+    omitted, all fields are included for each record, e.g.
+
+        {{ queryset | queryset_to_list:"id,name" }}
+
+    will return a list like
+
+        [{'id': 1, 'name': 'foo'}, ...]
+
+    (providing queryset has id and name fields)
+    """
+    if fields:
+        fields_list = [field.strip() for field in fields.split(',')]
+        return list(queryset.values(*fields_list))
+    else:
+        return list(queryset.values())
+
+register.filter('queryset_to_list', queryset_to_list)
diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
index 30295a7..60edb45 100755
--- a/bitbake/lib/toaster/toastergui/views.py
+++ b/bitbake/lib/toaster/toastergui/views.py
@@ -1257,7 +1257,10 @@ def recipes(request, build_id):
     if retval:
         return _redirect_parameters( 'recipes', request.GET, mandatory_parameters, build_id = build_id)
     (filter_string, search_term, ordering_string) = _search_tuple(request, Recipe)
-    queryset = Recipe.objects.filter(layer_version__id__in=Layer_Version.objects.filter(build=build_id)).select_related("layer_version", "layer_version__layer")
+
+    build = Build.objects.get(pk=build_id)
+
+    queryset = build.get_recipes()
     queryset = _get_queryset(Recipe, queryset, filter_string, search_term, ordering_string, 'name')
 
     recipes = _build_page_range(Paginator(queryset, pagesize),request.GET.get('page', 1))
@@ -1276,8 +1279,6 @@ def recipes(request, build_id):
             revlist.append(recipe_dep)
         revs[recipe.id] = revlist
 
-    build = Build.objects.get(pk=build_id)
-
     context = {
         'objectname': 'recipes',
         'build': build,
-- 
1.9.3

---------------------------------------------------------------------
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] 7+ messages in thread

* [PATCH 2/3] toaster: add modal to select custom image for editing
  2016-04-11 14:56 [PATCH 0/3] Add buttons to build dashboard to customise/edit images Elliot Smith
  2016-04-11 14:56 ` [PATCH 1/3] toaster: add build dashboard buttons to edit/create custom images Elliot Smith
@ 2016-04-11 14:56 ` Elliot Smith
  2016-04-13 17:11   ` Lerner, Dave
  2016-04-11 14:56 ` [PATCH 3/3] toaster-tests: tests for build dashboard Elliot Smith
  2 siblings, 1 reply; 7+ messages in thread
From: Elliot Smith @ 2016-04-11 14:56 UTC (permalink / raw)
  To: toaster

Add functionality to the placeholder button on the build dashboard
to open a modal dialog displaying editable custom images, in cases
where multiple custom images were built by the build. Where there
is only one editable custom image, go direct to its edit page.

The images shown in the modal are custom recipes for the project
which were built during the build shown in the dashboard.

This also affects the new custom image dialog, as that also has
to show custom image recipes as well as image recipes built during
the build. Modify the API on the Build object to support both.

Also modify and rename the queryset_to_list template filter so that
it can deal with lists as well as querysets, as the new custom image
modal has to show a list of image recipes which is an amalgam of two
querysets.

[YOCTO #9213]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 bitbake/lib/toaster/orm/models.py                  | 45 ++++++++------
 .../lib/toaster/toastergui/static/js/libtoaster.js |  2 +
 .../toastergui/static/js/newcustomimage_modal.js   |  7 ++-
 bitbake/lib/toaster/toastergui/templates/base.html |  1 -
 .../toastergui/templates/basebuildpage.html        | 62 +++++++++++---------
 .../templates/editcustomimage_modal.html           | 68 ++++++++++++++++++----
 .../templatetags/objects_to_dictionaries_filter.py | 35 +++++++++++
 .../templatetags/queryset_to_list_filter.py        | 26 ---------
 bitbake/lib/toaster/toastergui/views.py            | 26 +++++++--
 9 files changed, 182 insertions(+), 90 deletions(-)
 create mode 100644 bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
 delete mode 100644 bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py

diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py
index c63d631..a146541 100644
--- a/bitbake/lib/toaster/orm/models.py
+++ b/bitbake/lib/toaster/orm/models.py
@@ -497,33 +497,37 @@ class Build(models.Model):
         return Recipe.objects.filter(criteria) \
                              .select_related('layer_version', 'layer_version__layer')
 
-    def get_custom_image_recipe_names(self):
-        """
-        Get the names of custom image recipes for this build's project
-        as a list; this is used to screen out custom image recipes from the
-        recipes for the build by name, and to distinguish image recipes from
-        custom image recipes
-        """
-        custom_image_recipes = \
-            CustomImageRecipe.objects.filter(project=self.project)
-        return custom_image_recipes.values_list('name', flat=True)
-
     def get_image_recipes(self):
         """
-        Returns a queryset of image recipes related to this build, sorted
-        by name
+        Returns a list of image Recipes (custom and built-in) related to this
+        build, sorted by name; note that this has to be done in two steps, as
+        there's no way to get all the custom image recipes and image recipes
+        in one query
         """
-        criteria = Q(is_image=True)
-        return self.get_recipes().filter(criteria).order_by('name')
+        custom_image_recipes = self.get_custom_image_recipes()
+        custom_image_recipe_names = custom_image_recipes.values_list('name', flat=True)
+
+        not_custom_image_recipes = ~Q(name__in=custom_image_recipe_names) & \
+                                   Q(is_image=True)
+
+        built_image_recipes = self.get_recipes().filter(not_custom_image_recipes)
+
+        # append to the custom image recipes and sort
+        customisable_image_recipes = list(
+            itertools.chain(custom_image_recipes, built_image_recipes)
+        )
+
+        return sorted(customisable_image_recipes, key=lambda recipe: recipe.name)
 
     def get_custom_image_recipes(self):
         """
-        Returns a queryset of custom image recipes related to this build,
+        Returns a queryset of CustomImageRecipes related to this build,
         sorted by name
         """
-        custom_image_recipe_names = self.get_custom_image_recipe_names()
-        criteria = Q(is_image=True) & Q(name__in=custom_image_recipe_names)
-        return self.get_recipes().filter(criteria).order_by('name')
+        built_recipe_names = self.get_recipes().values_list('name', flat=True)
+        criteria = Q(name__in=built_recipe_names) & Q(project=self.project)
+        queryset = CustomImageRecipe.objects.filter(criteria).order_by('name')
+        return queryset
 
     def get_outcome_text(self):
         return Build.BUILD_OUTCOME[int(self.outcome)][1]
@@ -1374,6 +1378,9 @@ class Layer(models.Model):
 
 # LayerCommit class is synced with layerindex.LayerBranch
 class Layer_Version(models.Model):
+    """
+    A Layer_Version either belongs to a single project or no project
+    """
     search_allowed_fields = ["layer__name", "layer__summary", "layer__description", "layer__vcs_url", "dirpath", "up_branch__name", "commit", "branch"]
     build = models.ForeignKey(Build, related_name='layer_version_build', default = None, null = True)
     layer = models.ForeignKey(Layer, related_name='layer_version_layer')
diff --git a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
index 8d1d20f..88caaff 100644
--- a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
+++ b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
@@ -344,6 +344,8 @@ var libtoaster = (function (){
   }
 
   function _createCustomRecipe(name, baseRecipeId, doneCb){
+    debugger;
+
     var data = {
       'name' : name,
       'project' : libtoaster.ctx.projectId,
diff --git a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
index 1ae0d34..a6d5b1a 100644
--- a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
+++ b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
@@ -12,6 +12,7 @@ for the new custom image. This will manage the addition of radio buttons
 to select the base image (or remove the radio buttons, if there is only a
 single base image available).
 */
+
 function newCustomImageModalInit(){
 
   var newCustomImgBtn = $("#create-new-custom-image-btn");
@@ -112,13 +113,13 @@ function newCustomImageModalSetRecipes(baseRecipes) {
   var imageSelector = $('#new-custom-image-modal [data-role="image-selector"]');
   var imageSelectRadiosContainer = $('#new-custom-image-modal [data-role="image-selector-radios"]');
 
+  // remove any existing radio buttons + labels
+  imageSelector.remove('[data-role="image-radio"]');
+
   if (baseRecipes.length === 1) {
     // hide the radio button container
     imageSelector.hide();
 
-    // remove any radio buttons + labels
-    imageSelector.remove('[data-role="image-radio"]');
-
     // set the single recipe ID on the modal as it's the only one
     // we can build from
     imgCustomModal.data('recipe', baseRecipes[0].id);
diff --git a/bitbake/lib/toaster/toastergui/templates/base.html b/bitbake/lib/toaster/toastergui/templates/base.html
index 192f9fb..210cf33 100644
--- a/bitbake/lib/toaster/toastergui/templates/base.html
+++ b/bitbake/lib/toaster/toastergui/templates/base.html
@@ -43,7 +43,6 @@
         recipesTypeAheadUrl: {% url 'xhr_recipestypeahead' project.id as paturl%}{{paturl|json}},
         layersTypeAheadUrl: {% url 'xhr_layerstypeahead' project.id as paturl%}{{paturl|json}},
         machinesTypeAheadUrl: {% url 'xhr_machinestypeahead' project.id as paturl%}{{paturl|json}},
-
         projectBuildsUrl: {% url 'projectbuilds' project.id as pburl %}{{pburl|json}},
         xhrCustomRecipeUrl : "{% url 'xhr_customrecipe' %}",
         projectId : {{project.id}},
diff --git a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
index 4a8e2a7..0d8c882 100644
--- a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
+++ b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
@@ -1,7 +1,7 @@
 {% extends "base.html" %}
 {% load projecttags %}
 {% load project_url_tag %}
-{% load queryset_to_list_filter %}
+{% load objects_to_dictionaries_filter %}
 {% load humanize %}
 {% block pagecontent %}
   <!-- breadcrumbs -->
@@ -81,33 +81,40 @@
           </p>
         </li>
 
-        <li>
-          <!-- edit custom image built during this build -->
-          <p class="navbar-btn" data-role="edit-custom-image-trigger">
-            <button class="btn btn-block">Edit custom image</button>
-          </p>
-          {% include 'editcustomimage_modal.html' %}
-          <script>
-            $(document).ready(function () {
-              var editableCustomImageRecipes = {{ build.get_custom_image_recipes | queryset_to_list:"id,name" | json }};
-
-              // edit custom image which was built during this build
-              var editCustomImageModal = $('#edit-custom-image-modal');
-              var editCustomImageTrigger = $('[data-role="edit-custom-image-trigger"]');
+        {% with build.get_custom_image_recipes as custom_image_recipes %}
+          {% if custom_image_recipes.count > 0 %}
+            <!-- edit custom image built during this build -->
+            <li>
+              <p class="navbar-btn" data-role="edit-custom-image-trigger">
+                <button class="btn btn-block">Edit custom image</button>
+                {% include 'editcustomimage_modal.html' %}
+                <script>
+                  var editableCustomImageRecipes = {{ custom_image_recipes | objects_to_dictionaries:"id,name" | json }};
 
-              editCustomImageTrigger.click(function () {
-                // if there is a single editable custom image, go direct to the edit
-                // page for it; if there are multiple editable custom images, show
-                // dialog to select one of them for editing
+                  $(document).ready(function () {
+                    var editCustomImageTrigger = $('[data-role="edit-custom-image-trigger"]');
+                    var editCustomImageModal = $('#edit-custom-image-modal');
 
-                // single editable custom image
-
-                // multiple editable custom images
-                editCustomImageModal.modal('show');
-              });
-            });
-          </script>
-        </li>
+                    // edit custom image which was built during this build
+                    editCustomImageTrigger.click(function () {
+                      // single editable custom image: redirect to the edit page
+                      // for that image
+                      if (editableCustomImageRecipes.length === 1) {
+                        var url = '{% url "customrecipe" build.project.id custom_image_recipes.first.id %}';
+                        document.location.href = url;
+                      }
+                      // multiple editable custom images: show modal to select
+                      // one of them for editing
+                      else {
+                        editCustomImageModal.modal('show');
+                      }
+                    });
+                  });
+                </script>
+              </p>
+            </li>
+          {% endif %}
+        {% endwith %}
 
         <li>
           <!-- new custom image from image recipe in this build -->
@@ -119,7 +126,7 @@
             // imageRecipes includes both custom image recipes and built-in
             // image recipes, any of which can be used as the basis for a
             // new custom image
-            var imageRecipes = {{ build.get_image_recipes | queryset_to_list:"id,name" | json }};
+            var imageRecipes = {{ build.get_image_recipes | objects_to_dictionaries:"id,name" | json }};
 
             $(document).ready(function () {
               var newCustomImageModal = $('#new-custom-image-modal');
@@ -131,6 +138,7 @@
                 if (!imageRecipes.length) {
                   return;
                 }
+
                 newCustomImageModalSetRecipes(imageRecipes);
                 newCustomImageModal.modal('show');
               });
diff --git a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
index fd998f6..8046c08 100644
--- a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
+++ b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
@@ -1,23 +1,71 @@
 <!--
-modal dialog shown on the build dashboard, for editing an existing custom image
+modal dialog shown on the build dashboard, for editing an existing custom image;
+only shown if more than one custom image was built, so the user needs to
+choose which one to edit
+
+required context:
+  build - a Build object
 -->
 <div class="modal hide fade in" aria-hidden="false" id="edit-custom-image-modal">
   <div class="modal-header">
     <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
-    <h3>Select custom image to edit</h3>
+    <h3>Which image do you want to edit?</h3>
   </div>
+
   <div class="modal-body">
     <div class="row-fluid">
-      <span class="help-block">
-        Explanation of what this modal is for
-      </span>
-    </div>
-    <div class="control-group controls">
-      <input type="text" class="huge" placeholder="input box" required>
-      <span class="help-block error" style="display:none">Error text</span>
+      {% for recipe in build.get_custom_image_recipes %}
+        <label class="radio">
+          {{recipe.name}}
+          <input type="radio" class="form-control" name="select-custom-image"
+                 data-url="{% url 'customrecipe' build.project.id recipe.id %}">
+        </label>
+      {% endfor %}
     </div>
+    <span class="help-block error" id="invalid-custom-image-help" style="display:none">
+      Please select a custom image to edit.
+    </span>
   </div>
+
   <div class="modal-footer">
-    <button class="btn btn-primary btn-large" disabled>Action</button>
+    <button class="btn btn-primary btn-large" data-url="#"
+       data-action="edit-custom-image" disabled>
+      Edit custom image
+    </button>
   </div>
 </div>
+
+<script>
+$(document).ready(function () {
+  var editCustomImageButton = $('[data-action="edit-custom-image"]');
+  var error = $('#invalid-custom-image-help');
+  var radios = $('[name="select-custom-image"]');
+
+  // return custom image radio buttons which are selected
+  var getSelectedRadios = function () {
+    return $('[name="select-custom-image"]:checked');
+  };
+
+  radios.change(function () {
+    if (getSelectedRadios().length === 1) {
+      editCustomImageButton.removeAttr('disabled');
+      error.hide();
+    }
+    else {
+      editCustomImageButton.attr('disabled', 'disabled');
+      error.show();
+    }
+  });
+
+  editCustomImageButton.click(function () {
+    var selectedRadios = getSelectedRadios();
+
+    if (selectedRadios.length === 1) {
+      document.location.href = selectedRadios.first().attr('data-url');
+    }
+    else {
+      error.show();
+    }
+  });
+});
+</script>
diff --git a/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py b/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
new file mode 100644
index 0000000..0dcc7d2
--- /dev/null
+++ b/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
@@ -0,0 +1,35 @@
+from django import template
+import json
+
+register = template.Library()
+
+def objects_to_dictionaries(iterable, fields):
+    """
+    Convert an iterable into a list of dictionaries; fields should be set
+    to a comma-separated string of properties for each item included in the
+    resulting list; e.g. for a queryset:
+
+        {{ queryset | objects_to_dictionaries:"id,name" }}
+
+    will return a list like
+
+        [{'id': 1, 'name': 'foo'}, ...]
+
+    providing queryset has id and name fields
+
+    This is mostly to support serialising querysets or lists of model objects
+    to JSON
+    """
+    objects = []
+
+    if fields:
+        fields_list = [field.strip() for field in fields.split(',')]
+        for item in iterable:
+            out = {}
+            for field in fields_list:
+                out[field] = getattr(item, field)
+            objects.append(out)
+
+    return objects
+
+register.filter('objects_to_dictionaries', objects_to_dictionaries)
diff --git a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py b/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
deleted file mode 100644
index dfc094b..0000000
--- a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from django import template
-import json
-
-register = template.Library()
-
-def queryset_to_list(queryset, fields):
-    """
-    Convert a queryset to a list; fields can be set to a comma-separated
-    string of fields for each record included in the resulting list; if
-    omitted, all fields are included for each record, e.g.
-
-        {{ queryset | queryset_to_list:"id,name" }}
-
-    will return a list like
-
-        [{'id': 1, 'name': 'foo'}, ...]
-
-    (providing queryset has id and name fields)
-    """
-    if fields:
-        fields_list = [field.strip() for field in fields.split(',')]
-        return list(queryset.values(*fields_list))
-    else:
-        return list(queryset.values())
-
-register.filter('queryset_to_list', queryset_to_list)
diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
index 60edb45..1f824ee 100755
--- a/bitbake/lib/toaster/toastergui/views.py
+++ b/bitbake/lib/toaster/toastergui/views.py
@@ -507,6 +507,7 @@ def builddashboard( request, build_id ):
 
     context = {
             'build'           : build,
+            'project'         : build.project,
             'hasImages'       : hasImages,
             'ntargets'        : ntargets,
             'targets'         : targets,
@@ -797,6 +798,7 @@ eans multiple licenses exist that cover different parts of the source',
     context = {
         'objectname': variant,
         'build'                : build,
+        'project'              : build.project,
         'target'               : Target.objects.filter( pk = target_id )[ 0 ],
         'objects'              : packages,
         'packages_sum'         : packages_sum[ 'installed_size__sum' ],
@@ -937,7 +939,10 @@ def dirinfo(request, build_id, target_id, file_path=None):
             if head != sep:
                 dir_list.insert(0, head)
 
-    context = { 'build': Build.objects.get(pk=build_id),
+    build = Build.objects.get(pk=build_id)
+
+    context = { 'build': build,
+                'project': build.project,
                 'target': Target.objects.get(pk=target_id),
                 'packages_sum': packages_sum['installed_size__sum'],
                 'objects': objects,
@@ -1211,6 +1216,7 @@ def tasks_common(request, build_id, variant, task_anchor):
                 'filter_search_display': filter_search_display,
                 'mainheading': title_variant,
                 'build': build,
+                'project': build.project,
                 'objects': task_objects,
                 'default_orderby' : orderby,
                 'search_term': search_term,
@@ -1282,6 +1288,7 @@ def recipes(request, build_id):
     context = {
         'objectname': 'recipes',
         'build': build,
+        'project': build.project,
         'objects': recipes,
         'default_orderby' : 'name:+',
         'recipe_deps' : deps,
@@ -1366,10 +1373,12 @@ def configuration(request, build_id):
                  'MACHINE', 'DISTRO', 'DISTRO_VERSION', 'TUNE_FEATURES', 'TARGET_FPU')
     context = dict(Variable.objects.filter(build=build_id, variable_name__in=var_names)\
                                            .values_list('variable_name', 'variable_value'))
+    build = Build.objects.get(pk=build_id)
     context.update({'objectname': 'configuration',
                     'object_search_display':'variables',
                     'filter_search_display':'variables',
-                    'build': Build.objects.get(pk=build_id),
+                    'build': build,
+                    'project': build.project,
                     'targets': Target.objects.filter(build=build_id)})
     return render(request, template, context)
 
@@ -1406,12 +1415,15 @@ def configvars(request, build_id):
         file_filter += '/bitbake.conf'
     build_dir=re.sub("/tmp/log/.*","",Build.objects.get(pk=build_id).cooker_log_path)
 
+    build = Build.objects.get(pk=build_id)
+
     context = {
                 'objectname': 'configvars',
                 'object_search_display':'BitBake variables',
                 'filter_search_display':'variables',
                 'file_filter': file_filter,
-                'build': Build.objects.get(pk=build_id),
+                'build': build,
+                'project': build.project,
                 'objects' : variables,
                 'total_count':queryset_with_search.count(),
                 'default_orderby' : 'variable_name:+',
@@ -1480,6 +1492,7 @@ def bpackage(request, build_id):
     context = {
         'objectname': 'packages built',
         'build': build,
+        'project': build.project,
         'objects' : packages,
         'default_orderby' : 'name:+',
         'tablecols':[
@@ -1554,7 +1567,12 @@ def bpackage(request, build_id):
 def bfile(request, build_id, package_id):
     template = 'bfile.html'
     files = Package_File.objects.filter(package = package_id)
-    context = {'build': Build.objects.get(pk=build_id), 'objects' : files}
+    build = Build.objects.get(pk=build_id)
+    context = {
+        'build': build,
+        'project': build.project,
+        'objects' : files
+    }
     return render(request, template, context)
 
 
-- 
1.9.3

---------------------------------------------------------------------
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] 7+ messages in thread

* [PATCH 3/3] toaster-tests: tests for build dashboard
  2016-04-11 14:56 [PATCH 0/3] Add buttons to build dashboard to customise/edit images Elliot Smith
  2016-04-11 14:56 ` [PATCH 1/3] toaster: add build dashboard buttons to edit/create custom images Elliot Smith
  2016-04-11 14:56 ` [PATCH 2/3] toaster: add modal to select custom image for editing Elliot Smith
@ 2016-04-11 14:56 ` Elliot Smith
  2 siblings, 0 replies; 7+ messages in thread
From: Elliot Smith @ 2016-04-11 14:56 UTC (permalink / raw)
  To: toaster

Convert existing tests to Selenium.

Add basic tests to check that the modal contains radio buttons to select
a custom image to edit when a build built multiple custom images, and
to create a new custom image from one of the images built during
the build.

[YOCTO #9123]

Signed-off-by: Elliot Smith <elliot.smith@intel.com>
---
 .../tests/browser/test_builddashboard_page.py      | 251 +++++++++++++++++++++
 bitbake/lib/toaster/toastergui/tests.py            |  87 -------
 2 files changed, 251 insertions(+), 87 deletions(-)
 create mode 100644 bitbake/lib/toaster/tests/browser/test_builddashboard_page.py

diff --git a/bitbake/lib/toaster/tests/browser/test_builddashboard_page.py b/bitbake/lib/toaster/tests/browser/test_builddashboard_page.py
new file mode 100644
index 0000000..5e08749
--- /dev/null
+++ b/bitbake/lib/toaster/tests/browser/test_builddashboard_page.py
@@ -0,0 +1,251 @@
+#! /usr/bin/env python
+# ex:ts=4:sw=4:sts=4:et
+# -*- tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*-
+#
+# BitBake Toaster Implementation
+#
+# Copyright (C) 2013-2016 Intel Corporation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+from django.core.urlresolvers import reverse
+from django.utils import timezone
+
+from selenium_helpers import SeleniumTestCase
+
+from orm.models import Project, Release, BitbakeVersion, Build, LogMessage
+from orm.models import Layer, Layer_Version, Recipe, CustomImageRecipe
+
+class TestBuildDashboardPage(SeleniumTestCase):
+    """ Tests for the build dashboard /build/X """
+
+    def setUp(self):
+        bbv = BitbakeVersion.objects.create(name='bbv1', giturl='/tmp/',
+                                            branch='master', dirpath="")
+        release = Release.objects.create(name='release1',
+                                         bitbake_version=bbv)
+        project = Project.objects.create_project(name='test project',
+                                                 release=release)
+
+        now = timezone.now()
+
+        self.build1 = Build.objects.create(project=project,
+                                           started_on=now,
+                                           completed_on=now)
+
+        self.build2 = Build.objects.create(project=project,
+                                           started_on=now,
+                                           completed_on=now)
+
+        # exception
+        msg1 = 'an exception was thrown'
+        self.exception_message = LogMessage.objects.create(
+            build=self.build1,
+            level=LogMessage.EXCEPTION,
+            message=msg1
+        )
+
+        # critical
+        msg2 = 'a critical error occurred'
+        self.critical_message = LogMessage.objects.create(
+            build=self.build1,
+            level=LogMessage.CRITICAL,
+            message=msg2
+        )
+
+        # recipes related to the build, for testing the edit custom image/new
+        # custom image buttons
+        layer = Layer.objects.create(name='alayer')
+        layer_version = Layer_Version.objects.create(
+            layer=layer, build=self.build1
+        )
+
+        # image recipes
+        self.image_recipe1 = Recipe.objects.create(
+            name='recipeA',
+            layer_version=layer_version,
+            file_path='/foo/recipeA.bb',
+            is_image=True
+        )
+        self.image_recipe2 = Recipe.objects.create(
+            name='recipeB',
+            layer_version=layer_version,
+            file_path='/foo/recipeB.bb',
+            is_image=True
+        )
+
+        # custom image recipes for this project
+        self.custom_image_recipe1 = CustomImageRecipe.objects.create(
+            name='customRecipeY',
+            project=project,
+            layer_version=layer_version,
+            file_path='/foo/customRecipeY.bb',
+            base_recipe=self.image_recipe1,
+            is_image=True
+        )
+        self.custom_image_recipe2 = CustomImageRecipe.objects.create(
+            name='customRecipeZ',
+            project=project,
+            layer_version=layer_version,
+            file_path='/foo/customRecipeZ.bb',
+            base_recipe=self.image_recipe2,
+            is_image=True
+        )
+
+        # custom image recipe for a different project (to test filtering
+        # of image recipes and custom image recipes is correct: this shouldn't
+        # show up in either query against self.build1)
+        self.custom_image_recipe3 = CustomImageRecipe.objects.create(
+            name='customRecipeOmega',
+            project=Project.objects.create(name='baz', release=release),
+            layer_version=Layer_Version.objects.create(
+                layer=layer, build=self.build2
+            ),
+            file_path='/foo/customRecipeOmega.bb',
+            base_recipe=self.image_recipe2,
+            is_image=True
+        )
+
+        # another non-image recipe (to test filtering of image recipes and
+        # custom image recipes is correct: this shouldn't show up in either
+        # for any build)
+        self.non_image_recipe = Recipe.objects.create(
+            name='nonImageRecipe',
+            layer_version=layer_version,
+            file_path='/foo/nonImageRecipe.bb',
+            is_image=False
+        )
+
+    def _get_build_dashboard(self, build):
+        """
+        Navigate to the build dashboard for build
+        """
+        url = reverse('builddashboard', args=(build.id,))
+        self.get(url)
+
+    def _get_build_dashboard_errors(self, build):
+        """
+        Get a list of HTML fragments representing the errors on the
+        dashboard for the Build object build
+        """
+        self._get_build_dashboard(build)
+        return self.find_all('#errors div.alert-error')
+
+    def _check_for_log_message(self, build, log_message):
+        """
+        Check whether the LogMessage instance <log_message> is
+        represented as an HTML error in the dashboard page for the Build object
+        build
+        """
+        errors = self._get_build_dashboard_errors(build)
+        self.assertEqual(len(errors), 2)
+
+        expected_text = log_message.message
+        expected_id = str(log_message.id)
+
+        found = False
+        for error in errors:
+            error_text = error.find_element_by_tag_name('pre').text
+            text_matches = (error_text == expected_text)
+
+            error_id = error.get_attribute('data-error')
+            id_matches = (error_id == expected_id)
+
+            if text_matches and id_matches:
+                found = True
+                break
+
+        template_vars = (expected_text, error_text,
+                         expected_id, error_id)
+        assertion_error_msg = 'exception not found as error: ' \
+            'expected text "%s" and got "%s"; ' \
+            'expected ID %s and got %s' % template_vars
+        self.assertTrue(found, assertion_error_msg)
+
+    def _check_labels_in_modal(self, modal, expected):
+        """
+        Check that the text values of the <label> elements inside
+        the WebElement modal match the list of text values in expected
+        """
+        # labels containing the radio buttons we're testing for
+        labels = modal.find_elements_by_tag_name('label')
+
+        # because the label content has the structure
+        #   label text
+        #   <input...>
+        # we have to regex on its innerHTML, as we can't just retrieve the
+        # "label text" on its own via the Selenium API
+        labels_text = sorted(map(
+            lambda label: label.get_attribute('innerHTML'), labels
+        ))
+
+        expected = sorted(expected)
+
+        self.assertEqual(len(labels_text), len(expected))
+
+        for idx, label_text in enumerate(labels_text):
+            self.assertRegexpMatches(label_text, expected[idx])
+
+    def test_exceptions_show_as_errors(self):
+        """
+        LogMessages with level EXCEPTION should display in the errors
+        section of the page
+        """
+        self._check_for_log_message(self.build1, self.exception_message)
+
+    def test_criticals_show_as_errors(self):
+        """
+        LogMessages with level CRITICAL should display in the errors
+        section of the page
+        """
+        self._check_for_log_message(self.build1, self.critical_message)
+
+    def test_edit_custom_image_button(self):
+        """
+        A build which built two custom images should present a modal which lets
+        the user choose one of them to edit
+        """
+        self._get_build_dashboard(self.build1)
+        modal = self.driver.find_element_by_id('edit-custom-image-modal')
+
+        # recipes we expect to see in the edit custom image modal
+        expected_recipes = [
+            self.custom_image_recipe1.name,
+            self.custom_image_recipe2.name
+        ]
+
+        self._check_labels_in_modal(modal, expected_recipes)
+
+    def test_new_custom_image_button(self):
+        """
+        Check that a build with multiple images and custom images presents
+        all of them as options for creating a new custom image from
+        """
+        self._get_build_dashboard(self.build1)
+
+        # click the "new custom image" button, which populates the modal
+        selector = '[data-role="new-custom-image-trigger"] button'
+        self.click(selector)
+
+        modal = self.driver.find_element_by_id('new-custom-image-modal')
+
+        # recipes we expect to see in the new custom image modal
+        expected_recipes = [
+            self.image_recipe1.name,
+            self.image_recipe2.name,
+            self.custom_image_recipe1.name,
+            self.custom_image_recipe2.name
+        ]
+
+        self._check_labels_in_modal(modal, expected_recipes)
diff --git a/bitbake/lib/toaster/toastergui/tests.py b/bitbake/lib/toaster/toastergui/tests.py
index eebd1b7..a4cab58 100644
--- a/bitbake/lib/toaster/toastergui/tests.py
+++ b/bitbake/lib/toaster/toastergui/tests.py
@@ -492,90 +492,3 @@ class ViewTests(TestCase):
                                 page_two_data,
                                 "Changed page on table %s but first row is the "
                                 "same as the previous page" % name)
-
-class BuildDashboardTests(TestCase):
-    """ Tests for the build dashboard /build/X """
-
-    def setUp(self):
-        bbv = BitbakeVersion.objects.create(name="bbv1", giturl="/tmp/",
-                                            branch="master", dirpath="")
-        release = Release.objects.create(name="release1",
-                                         bitbake_version=bbv)
-        project = Project.objects.create_project(name=PROJECT_NAME,
-                                                 release=release)
-
-        now = timezone.now()
-
-        self.build1 = Build.objects.create(project=project,
-                                           started_on=now,
-                                           completed_on=now)
-
-        # exception
-        msg1 = 'an exception was thrown'
-        self.exception_message = LogMessage.objects.create(
-            build=self.build1,
-            level=LogMessage.EXCEPTION,
-            message=msg1
-        )
-
-        # critical
-        msg2 = 'a critical error occurred'
-        self.critical_message = LogMessage.objects.create(
-            build=self.build1,
-            level=LogMessage.CRITICAL,
-            message=msg2
-        )
-
-    def _get_build_dashboard_errors(self):
-        """
-        Get a list of HTML fragments representing the errors on the
-        build dashboard
-        """
-        url = reverse('builddashboard', args=(self.build1.id,))
-        response = self.client.get(url)
-        soup = BeautifulSoup(response.content)
-        return soup.select('#errors div.alert-error')
-
-    def _check_for_log_message(self, log_message):
-        """
-        Check whether the LogMessage instance <log_message> is
-        represented as an HTML error in the build dashboard page
-        """
-        errors = self._get_build_dashboard_errors()
-        self.assertEqual(len(errors), 2)
-
-        expected_text = log_message.message
-        expected_id = str(log_message.id)
-
-        found = False
-        for error in errors:
-            error_text = error.find('pre').text
-            text_matches = (error_text == expected_text)
-
-            error_id = error['data-error']
-            id_matches = (error_id == expected_id)
-
-            if text_matches and id_matches:
-                found = True
-                break
-
-        template_vars = (expected_text, error_text,
-                         expected_id, error_id)
-        assertion_error_msg = 'exception not found as error: ' \
-            'expected text "%s" and got "%s"; ' \
-            'expected ID %s and got %s' % template_vars
-        self.assertTrue(found, assertion_error_msg)
-
-    def test_exceptions_show_as_errors(self):
-        """
-        LogMessages with level EXCEPTION should display in the errors
-        section of the page
-        """
-        self._check_for_log_message(self.exception_message)
-
-    def test_criticals_show_as_errors(self):
-        """
-        LogMessages with level CRITICAL should display in the errors
-        section of the page
-        """
-        self._check_for_log_message(self.critical_message)
-- 
1.9.3

---------------------------------------------------------------------
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] 7+ messages in thread

* Re: [PATCH 2/3] toaster: add modal to select custom image for editing
  2016-04-11 14:56 ` [PATCH 2/3] toaster: add modal to select custom image for editing Elliot Smith
@ 2016-04-13 17:11   ` Lerner, Dave
  2016-04-14  7:27     ` Smith, Elliot
  0 siblings, 1 reply; 7+ messages in thread
From: Lerner, Dave @ 2016-04-13 17:11 UTC (permalink / raw)
  To: SMITH, ELLIOT, toaster

Hi Elliot,
While reviewing open RRs, I noticed that you transposed the yocto number.  It should be #9123.
Dave

> -----Original Message-----
> From: toaster-bounces@yoctoproject.org [mailto:toaster-bounces@yoctoproject.org] On
> Behalf Of Elliot Smith
> Sent: Monday, April 11, 2016 9:56 AM
> To: toaster@yoctoproject.org
> Subject: [Toaster] [PATCH 2/3] toaster: add modal to select custom image for editing
> 
> Add functionality to the placeholder button on the build dashboard
> to open a modal dialog displaying editable custom images, in cases
> where multiple custom images were built by the build. Where there
> is only one editable custom image, go direct to its edit page.
> 
> The images shown in the modal are custom recipes for the project
> which were built during the build shown in the dashboard.
> 
> This also affects the new custom image dialog, as that also has
> to show custom image recipes as well as image recipes built during
> the build. Modify the API on the Build object to support both.
> 
> Also modify and rename the queryset_to_list template filter so that
> it can deal with lists as well as querysets, as the new custom image
> modal has to show a list of image recipes which is an amalgam of two
> querysets.
> 
> [YOCTO #9213]

Bug 9213 - Enable thumb for ARM builds
Bug 9123 - Build history pages are missing the image customisation links

> 
> Signed-off-by: Elliot Smith <elliot.smith@intel.com>
> ---
>  bitbake/lib/toaster/orm/models.py                  | 45 ++++++++------
>  .../lib/toaster/toastergui/static/js/libtoaster.js |  2 +
>  .../toastergui/static/js/newcustomimage_modal.js   |  7 ++-
>  bitbake/lib/toaster/toastergui/templates/base.html |  1 -
>  .../toastergui/templates/basebuildpage.html        | 62 +++++++++++---------
>  .../templates/editcustomimage_modal.html           | 68 ++++++++++++++++++----
>  .../templatetags/objects_to_dictionaries_filter.py | 35 +++++++++++
>  .../templatetags/queryset_to_list_filter.py        | 26 ---------
>  bitbake/lib/toaster/toastergui/views.py            | 26 +++++++--
>  9 files changed, 182 insertions(+), 90 deletions(-)
>  create mode 100644
> bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
>  delete mode 100644
> bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> 
> diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py
> index c63d631..a146541 100644
> --- a/bitbake/lib/toaster/orm/models.py
> +++ b/bitbake/lib/toaster/orm/models.py
> @@ -497,33 +497,37 @@ class Build(models.Model):
>          return Recipe.objects.filter(criteria) \
>                               .select_related('layer_version', 'layer_version__layer')
> 
> -    def get_custom_image_recipe_names(self):
> -        """
> -        Get the names of custom image recipes for this build's project
> -        as a list; this is used to screen out custom image recipes from the
> -        recipes for the build by name, and to distinguish image recipes from
> -        custom image recipes
> -        """
> -        custom_image_recipes = \
> -            CustomImageRecipe.objects.filter(project=self.project)
> -        return custom_image_recipes.values_list('name', flat=True)
> -
>      def get_image_recipes(self):
>          """
> -        Returns a queryset of image recipes related to this build, sorted
> -        by name
> +        Returns a list of image Recipes (custom and built-in) related to this
> +        build, sorted by name; note that this has to be done in two steps, as
> +        there's no way to get all the custom image recipes and image recipes
> +        in one query
>          """
> -        criteria = Q(is_image=True)
> -        return self.get_recipes().filter(criteria).order_by('name')
> +        custom_image_recipes = self.get_custom_image_recipes()
> +        custom_image_recipe_names = custom_image_recipes.values_list('name', flat=True)
> +
> +        not_custom_image_recipes = ~Q(name__in=custom_image_recipe_names) & \
> +                                   Q(is_image=True)
> +
> +        built_image_recipes = self.get_recipes().filter(not_custom_image_recipes)
> +
> +        # append to the custom image recipes and sort
> +        customisable_image_recipes = list(
> +            itertools.chain(custom_image_recipes, built_image_recipes)
> +        )
> +
> +        return sorted(customisable_image_recipes, key=lambda recipe: recipe.name)
> 
>      def get_custom_image_recipes(self):
>          """
> -        Returns a queryset of custom image recipes related to this build,
> +        Returns a queryset of CustomImageRecipes related to this build,
>          sorted by name
>          """
> -        custom_image_recipe_names = self.get_custom_image_recipe_names()
> -        criteria = Q(is_image=True) & Q(name__in=custom_image_recipe_names)
> -        return self.get_recipes().filter(criteria).order_by('name')
> +        built_recipe_names = self.get_recipes().values_list('name', flat=True)
> +        criteria = Q(name__in=built_recipe_names) & Q(project=self.project)
> +        queryset = CustomImageRecipe.objects.filter(criteria).order_by('name')
> +        return queryset
> 
>      def get_outcome_text(self):
>          return Build.BUILD_OUTCOME[int(self.outcome)][1]
> @@ -1374,6 +1378,9 @@ class Layer(models.Model):
> 
>  # LayerCommit class is synced with layerindex.LayerBranch
>  class Layer_Version(models.Model):
> +    """
> +    A Layer_Version either belongs to a single project or no project
> +    """
>      search_allowed_fields = ["layer__name", "layer__summary", "layer__description",
> "layer__vcs_url", "dirpath", "up_branch__name", "commit", "branch"]
>      build = models.ForeignKey(Build, related_name='layer_version_build', default =
> None, null = True)
>      layer = models.ForeignKey(Layer, related_name='layer_version_layer')
> diff --git a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> index 8d1d20f..88caaff 100644
> --- a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> +++ b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> @@ -344,6 +344,8 @@ var libtoaster = (function (){
>    }
> 
>    function _createCustomRecipe(name, baseRecipeId, doneCb){
> +    debugger;
> +
>      var data = {
>        'name' : name,
>        'project' : libtoaster.ctx.projectId,
> diff --git a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> index 1ae0d34..a6d5b1a 100644
> --- a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> +++ b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> @@ -12,6 +12,7 @@ for the new custom image. This will manage the addition of radio
> buttons
>  to select the base image (or remove the radio buttons, if there is only a
>  single base image available).
>  */
> +
>  function newCustomImageModalInit(){
> 
>    var newCustomImgBtn = $("#create-new-custom-image-btn");
> @@ -112,13 +113,13 @@ function newCustomImageModalSetRecipes(baseRecipes) {
>    var imageSelector = $('#new-custom-image-modal [data-role="image-selector"]');
>    var imageSelectRadiosContainer = $('#new-custom-image-modal [data-role="image-
> selector-radios"]');
> 
> +  // remove any existing radio buttons + labels
> +  imageSelector.remove('[data-role="image-radio"]');
> +
>    if (baseRecipes.length === 1) {
>      // hide the radio button container
>      imageSelector.hide();
> 
> -    // remove any radio buttons + labels
> -    imageSelector.remove('[data-role="image-radio"]');
> -
>      // set the single recipe ID on the modal as it's the only one
>      // we can build from
>      imgCustomModal.data('recipe', baseRecipes[0].id);
> diff --git a/bitbake/lib/toaster/toastergui/templates/base.html
> b/bitbake/lib/toaster/toastergui/templates/base.html
> index 192f9fb..210cf33 100644
> --- a/bitbake/lib/toaster/toastergui/templates/base.html
> +++ b/bitbake/lib/toaster/toastergui/templates/base.html
> @@ -43,7 +43,6 @@
>          recipesTypeAheadUrl: {% url 'xhr_recipestypeahead' project.id as
> paturl%}{{paturl|json}},
>          layersTypeAheadUrl: {% url 'xhr_layerstypeahead' project.id as
> paturl%}{{paturl|json}},
>          machinesTypeAheadUrl: {% url 'xhr_machinestypeahead' project.id as
> paturl%}{{paturl|json}},
> -
>          projectBuildsUrl: {% url 'projectbuilds' project.id as pburl %}{{pburl|json}},
>          xhrCustomRecipeUrl : "{% url 'xhr_customrecipe' %}",
>          projectId : {{project.id}},
> diff --git a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> index 4a8e2a7..0d8c882 100644
> --- a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> +++ b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> @@ -1,7 +1,7 @@
>  {% extends "base.html" %}
>  {% load projecttags %}
>  {% load project_url_tag %}
> -{% load queryset_to_list_filter %}
> +{% load objects_to_dictionaries_filter %}
>  {% load humanize %}
>  {% block pagecontent %}
>    <!-- breadcrumbs -->
> @@ -81,33 +81,40 @@
>            </p>
>          </li>
> 
> -        <li>
> -          <!-- edit custom image built during this build -->
> -          <p class="navbar-btn" data-role="edit-custom-image-trigger">
> -            <button class="btn btn-block">Edit custom image</button>
> -          </p>
> -          {% include 'editcustomimage_modal.html' %}
> -          <script>
> -            $(document).ready(function () {
> -              var editableCustomImageRecipes = {{ build.get_custom_image_recipes |
> queryset_to_list:"id,name" | json }};
> -
> -              // edit custom image which was built during this build
> -              var editCustomImageModal = $('#edit-custom-image-modal');
> -              var editCustomImageTrigger = $('[data-role="edit-custom-image-
> trigger"]');
> +        {% with build.get_custom_image_recipes as custom_image_recipes %}
> +          {% if custom_image_recipes.count > 0 %}
> +            <!-- edit custom image built during this build -->
> +            <li>
> +              <p class="navbar-btn" data-role="edit-custom-image-trigger">
> +                <button class="btn btn-block">Edit custom image</button>
> +                {% include 'editcustomimage_modal.html' %}
> +                <script>
> +                  var editableCustomImageRecipes = {{ custom_image_recipes |
> objects_to_dictionaries:"id,name" | json }};
> 
> -              editCustomImageTrigger.click(function () {
> -                // if there is a single editable custom image, go direct to the edit
> -                // page for it; if there are multiple editable custom images, show
> -                // dialog to select one of them for editing
> +                  $(document).ready(function () {
> +                    var editCustomImageTrigger = $('[data-role="edit-custom-image-
> trigger"]');
> +                    var editCustomImageModal = $('#edit-custom-image-modal');
> 
> -                // single editable custom image
> -
> -                // multiple editable custom images
> -                editCustomImageModal.modal('show');
> -              });
> -            });
> -          </script>
> -        </li>
> +                    // edit custom image which was built during this build
> +                    editCustomImageTrigger.click(function () {
> +                      // single editable custom image: redirect to the edit page
> +                      // for that image
> +                      if (editableCustomImageRecipes.length === 1) {
> +                        var url = '{% url "customrecipe" build.project.id
> custom_image_recipes.first.id %}';
> +                        document.location.href = url;
> +                      }
> +                      // multiple editable custom images: show modal to select
> +                      // one of them for editing
> +                      else {
> +                        editCustomImageModal.modal('show');
> +                      }
> +                    });
> +                  });
> +                </script>
> +              </p>
> +            </li>
> +          {% endif %}
> +        {% endwith %}
> 
>          <li>
>            <!-- new custom image from image recipe in this build -->
> @@ -119,7 +126,7 @@
>              // imageRecipes includes both custom image recipes and built-in
>              // image recipes, any of which can be used as the basis for a
>              // new custom image
> -            var imageRecipes = {{ build.get_image_recipes | queryset_to_list:"id,name"
> | json }};
> +            var imageRecipes = {{ build.get_image_recipes |
> objects_to_dictionaries:"id,name" | json }};
> 
>              $(document).ready(function () {
>                var newCustomImageModal = $('#new-custom-image-modal');
> @@ -131,6 +138,7 @@
>                  if (!imageRecipes.length) {
>                    return;
>                  }
> +
>                  newCustomImageModalSetRecipes(imageRecipes);
>                  newCustomImageModal.modal('show');
>                });
> diff --git a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> index fd998f6..8046c08 100644
> --- a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> +++ b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> @@ -1,23 +1,71 @@
>  <!--
> -modal dialog shown on the build dashboard, for editing an existing custom image
> +modal dialog shown on the build dashboard, for editing an existing custom image;
> +only shown if more than one custom image was built, so the user needs to
> +choose which one to edit
> +
> +required context:
> +  build - a Build object
>  -->
>  <div class="modal hide fade in" aria-hidden="false" id="edit-custom-image-modal">
>    <div class="modal-header">
>      <button type="button" class="close" data-dismiss="modal" aria-
> hidden="true">×</button>
> -    <h3>Select custom image to edit</h3>
> +    <h3>Which image do you want to edit?</h3>
>    </div>
> +
>    <div class="modal-body">
>      <div class="row-fluid">
> -      <span class="help-block">
> -        Explanation of what this modal is for
> -      </span>
> -    </div>
> -    <div class="control-group controls">
> -      <input type="text" class="huge" placeholder="input box" required>
> -      <span class="help-block error" style="display:none">Error text</span>
> +      {% for recipe in build.get_custom_image_recipes %}
> +        <label class="radio">
> +          {{recipe.name}}
> +          <input type="radio" class="form-control" name="select-custom-image"
> +                 data-url="{% url 'customrecipe' build.project.id recipe.id %}">
> +        </label>
> +      {% endfor %}
>      </div>
> +    <span class="help-block error" id="invalid-custom-image-help" style="display:none">
> +      Please select a custom image to edit.
> +    </span>
>    </div>
> +
>    <div class="modal-footer">
> -    <button class="btn btn-primary btn-large" disabled>Action</button>
> +    <button class="btn btn-primary btn-large" data-url="#"
> +       data-action="edit-custom-image" disabled>
> +      Edit custom image
> +    </button>
>    </div>
>  </div>
> +
> +<script>
> +$(document).ready(function () {
> +  var editCustomImageButton = $('[data-action="edit-custom-image"]');
> +  var error = $('#invalid-custom-image-help');
> +  var radios = $('[name="select-custom-image"]');
> +
> +  // return custom image radio buttons which are selected
> +  var getSelectedRadios = function () {
> +    return $('[name="select-custom-image"]:checked');
> +  };
> +
> +  radios.change(function () {
> +    if (getSelectedRadios().length === 1) {
> +      editCustomImageButton.removeAttr('disabled');
> +      error.hide();
> +    }
> +    else {
> +      editCustomImageButton.attr('disabled', 'disabled');
> +      error.show();
> +    }
> +  });
> +
> +  editCustomImageButton.click(function () {
> +    var selectedRadios = getSelectedRadios();
> +
> +    if (selectedRadios.length === 1) {
> +      document.location.href = selectedRadios.first().attr('data-url');
> +    }
> +    else {
> +      error.show();
> +    }
> +  });
> +});
> +</script>
> diff --git
> a/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
> b/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
> new file mode 100644
> index 0000000..0dcc7d2
> --- /dev/null
> +++ b/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
> @@ -0,0 +1,35 @@
> +from django import template
> +import json
> +
> +register = template.Library()
> +
> +def objects_to_dictionaries(iterable, fields):
> +    """
> +    Convert an iterable into a list of dictionaries; fields should be set
> +    to a comma-separated string of properties for each item included in the
> +    resulting list; e.g. for a queryset:
> +
> +        {{ queryset | objects_to_dictionaries:"id,name" }}
> +
> +    will return a list like
> +
> +        [{'id': 1, 'name': 'foo'}, ...]
> +
> +    providing queryset has id and name fields
> +
> +    This is mostly to support serialising querysets or lists of model objects
> +    to JSON
> +    """
> +    objects = []
> +
> +    if fields:
> +        fields_list = [field.strip() for field in fields.split(',')]
> +        for item in iterable:
> +            out = {}
> +            for field in fields_list:
> +                out[field] = getattr(item, field)
> +            objects.append(out)
> +
> +    return objects
> +
> +register.filter('objects_to_dictionaries', objects_to_dictionaries)
> diff --git a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> b/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> deleted file mode 100644
> index dfc094b..0000000
> --- a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> +++ /dev/null
> @@ -1,26 +0,0 @@
> -from django import template
> -import json
> -
> -register = template.Library()
> -
> -def queryset_to_list(queryset, fields):
> -    """
> -    Convert a queryset to a list; fields can be set to a comma-separated
> -    string of fields for each record included in the resulting list; if
> -    omitted, all fields are included for each record, e.g.
> -
> -        {{ queryset | queryset_to_list:"id,name" }}
> -
> -    will return a list like
> -
> -        [{'id': 1, 'name': 'foo'}, ...]
> -
> -    (providing queryset has id and name fields)
> -    """
> -    if fields:
> -        fields_list = [field.strip() for field in fields.split(',')]
> -        return list(queryset.values(*fields_list))
> -    else:
> -        return list(queryset.values())
> -
> -register.filter('queryset_to_list', queryset_to_list)
> diff --git a/bitbake/lib/toaster/toastergui/views.py
> b/bitbake/lib/toaster/toastergui/views.py
> index 60edb45..1f824ee 100755
> --- a/bitbake/lib/toaster/toastergui/views.py
> +++ b/bitbake/lib/toaster/toastergui/views.py
> @@ -507,6 +507,7 @@ def builddashboard( request, build_id ):
> 
>      context = {
>              'build'           : build,
> +            'project'         : build.project,
>              'hasImages'       : hasImages,
>              'ntargets'        : ntargets,
>              'targets'         : targets,
> @@ -797,6 +798,7 @@ eans multiple licenses exist that cover different parts of the
> source',
>      context = {
>          'objectname': variant,
>          'build'                : build,
> +        'project'              : build.project,
>          'target'               : Target.objects.filter( pk = target_id )[ 0 ],
>          'objects'              : packages,
>          'packages_sum'         : packages_sum[ 'installed_size__sum' ],
> @@ -937,7 +939,10 @@ def dirinfo(request, build_id, target_id, file_path=None):
>              if head != sep:
>                  dir_list.insert(0, head)
> 
> -    context = { 'build': Build.objects.get(pk=build_id),
> +    build = Build.objects.get(pk=build_id)
> +
> +    context = { 'build': build,
> +                'project': build.project,
>                  'target': Target.objects.get(pk=target_id),
>                  'packages_sum': packages_sum['installed_size__sum'],
>                  'objects': objects,
> @@ -1211,6 +1216,7 @@ def tasks_common(request, build_id, variant, task_anchor):
>                  'filter_search_display': filter_search_display,
>                  'mainheading': title_variant,
>                  'build': build,
> +                'project': build.project,
>                  'objects': task_objects,
>                  'default_orderby' : orderby,
>                  'search_term': search_term,
> @@ -1282,6 +1288,7 @@ def recipes(request, build_id):
>      context = {
>          'objectname': 'recipes',
>          'build': build,
> +        'project': build.project,
>          'objects': recipes,
>          'default_orderby' : 'name:+',
>          'recipe_deps' : deps,
> @@ -1366,10 +1373,12 @@ def configuration(request, build_id):
>                   'MACHINE', 'DISTRO', 'DISTRO_VERSION', 'TUNE_FEATURES', 'TARGET_FPU')
>      context = dict(Variable.objects.filter(build=build_id,
> variable_name__in=var_names)\
>                                             .values_list('variable_name',
> 'variable_value'))
> +    build = Build.objects.get(pk=build_id)
>      context.update({'objectname': 'configuration',
>                      'object_search_display':'variables',
>                      'filter_search_display':'variables',
> -                    'build': Build.objects.get(pk=build_id),
> +                    'build': build,
> +                    'project': build.project,
>                      'targets': Target.objects.filter(build=build_id)})
>      return render(request, template, context)
> 
> @@ -1406,12 +1415,15 @@ def configvars(request, build_id):
>          file_filter += '/bitbake.conf'
>      build_dir=re.sub("/tmp/log/.*","",Build.objects.get(pk=build_id).cooker_log_path)
> 
> +    build = Build.objects.get(pk=build_id)
> +
>      context = {
>                  'objectname': 'configvars',
>                  'object_search_display':'BitBake variables',
>                  'filter_search_display':'variables',
>                  'file_filter': file_filter,
> -                'build': Build.objects.get(pk=build_id),
> +                'build': build,
> +                'project': build.project,
>                  'objects' : variables,
>                  'total_count':queryset_with_search.count(),
>                  'default_orderby' : 'variable_name:+',
> @@ -1480,6 +1492,7 @@ def bpackage(request, build_id):
>      context = {
>          'objectname': 'packages built',
>          'build': build,
> +        'project': build.project,
>          'objects' : packages,
>          'default_orderby' : 'name:+',
>          'tablecols':[
> @@ -1554,7 +1567,12 @@ def bpackage(request, build_id):
>  def bfile(request, build_id, package_id):
>      template = 'bfile.html'
>      files = Package_File.objects.filter(package = package_id)
> -    context = {'build': Build.objects.get(pk=build_id), 'objects' : files}
> +    build = Build.objects.get(pk=build_id)
> +    context = {
> +        'build': build,
> +        'project': build.project,
> +        'objects' : files
> +    }
>      return render(request, template, context)
> 
> 
> --
> 1.9.3
> 
> ---------------------------------------------------------------------
> 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.
> --
> _______________________________________________
> toaster mailing list
> toaster@yoctoproject.org
> https://lists.yoctoproject.org/listinfo/toaster

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

* Re: [PATCH 2/3] toaster: add modal to select custom image for editing
  2016-04-13 17:11   ` Lerner, Dave
@ 2016-04-14  7:27     ` Smith, Elliot
  0 siblings, 0 replies; 7+ messages in thread
From: Smith, Elliot @ 2016-04-14  7:27 UTC (permalink / raw)
  To: Lerner, David M (Wind River); +Cc: toaster

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

Thanks Dave, nice spot. I will fix it in the next version I submit for
review.

Elliot

On 13 April 2016 at 18:11, Lerner, David M (Wind River) <
dave.lerner@windriver.com> wrote:

> Hi Elliot,
> While reviewing open RRs, I noticed that you transposed the yocto number.
> It should be #9123.
> Dave
>
> > -----Original Message-----
> > From: toaster-bounces@yoctoproject.org [mailto:
> toaster-bounces@yoctoproject.org] On
> > Behalf Of Elliot Smith
> > Sent: Monday, April 11, 2016 9:56 AM
> > To: toaster@yoctoproject.org
> > Subject: [Toaster] [PATCH 2/3] toaster: add modal to select custom image
> for editing
> >
> > Add functionality to the placeholder button on the build dashboard
> > to open a modal dialog displaying editable custom images, in cases
> > where multiple custom images were built by the build. Where there
> > is only one editable custom image, go direct to its edit page.
> >
> > The images shown in the modal are custom recipes for the project
> > which were built during the build shown in the dashboard.
> >
> > This also affects the new custom image dialog, as that also has
> > to show custom image recipes as well as image recipes built during
> > the build. Modify the API on the Build object to support both.
> >
> > Also modify and rename the queryset_to_list template filter so that
> > it can deal with lists as well as querysets, as the new custom image
> > modal has to show a list of image recipes which is an amalgam of two
> > querysets.
> >
> > [YOCTO #9213]
>
> Bug 9213 - Enable thumb for ARM builds
> Bug 9123 - Build history pages are missing the image customisation links
>
> >
> > Signed-off-by: Elliot Smith <elliot.smith@intel.com>
> > ---
> >  bitbake/lib/toaster/orm/models.py                  | 45 ++++++++------
> >  .../lib/toaster/toastergui/static/js/libtoaster.js |  2 +
> >  .../toastergui/static/js/newcustomimage_modal.js   |  7 ++-
> >  bitbake/lib/toaster/toastergui/templates/base.html |  1 -
> >  .../toastergui/templates/basebuildpage.html        | 62
> +++++++++++---------
> >  .../templates/editcustomimage_modal.html           | 68
> ++++++++++++++++++----
> >  .../templatetags/objects_to_dictionaries_filter.py | 35 +++++++++++
> >  .../templatetags/queryset_to_list_filter.py        | 26 ---------
> >  bitbake/lib/toaster/toastergui/views.py            | 26 +++++++--
> >  9 files changed, 182 insertions(+), 90 deletions(-)
> >  create mode 100644
> >
> bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
> >  delete mode 100644
> > bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> >
> > diff --git a/bitbake/lib/toaster/orm/models.py
> b/bitbake/lib/toaster/orm/models.py
> > index c63d631..a146541 100644
> > --- a/bitbake/lib/toaster/orm/models.py
> > +++ b/bitbake/lib/toaster/orm/models.py
> > @@ -497,33 +497,37 @@ class Build(models.Model):
> >          return Recipe.objects.filter(criteria) \
> >                               .select_related('layer_version',
> 'layer_version__layer')
> >
> > -    def get_custom_image_recipe_names(self):
> > -        """
> > -        Get the names of custom image recipes for this build's project
> > -        as a list; this is used to screen out custom image recipes from
> the
> > -        recipes for the build by name, and to distinguish image recipes
> from
> > -        custom image recipes
> > -        """
> > -        custom_image_recipes = \
> > -            CustomImageRecipe.objects.filter(project=self.project)
> > -        return custom_image_recipes.values_list('name', flat=True)
> > -
> >      def get_image_recipes(self):
> >          """
> > -        Returns a queryset of image recipes related to this build,
> sorted
> > -        by name
> > +        Returns a list of image Recipes (custom and built-in) related
> to this
> > +        build, sorted by name; note that this has to be done in two
> steps, as
> > +        there's no way to get all the custom image recipes and image
> recipes
> > +        in one query
> >          """
> > -        criteria = Q(is_image=True)
> > -        return self.get_recipes().filter(criteria).order_by('name')
> > +        custom_image_recipes = self.get_custom_image_recipes()
> > +        custom_image_recipe_names =
> custom_image_recipes.values_list('name', flat=True)
> > +
> > +        not_custom_image_recipes =
> ~Q(name__in=custom_image_recipe_names) & \
> > +                                   Q(is_image=True)
> > +
> > +        built_image_recipes =
> self.get_recipes().filter(not_custom_image_recipes)
> > +
> > +        # append to the custom image recipes and sort
> > +        customisable_image_recipes = list(
> > +            itertools.chain(custom_image_recipes, built_image_recipes)
> > +        )
> > +
> > +        return sorted(customisable_image_recipes, key=lambda recipe:
> recipe.name)
> >
> >      def get_custom_image_recipes(self):
> >          """
> > -        Returns a queryset of custom image recipes related to this
> build,
> > +        Returns a queryset of CustomImageRecipes related to this build,
> >          sorted by name
> >          """
> > -        custom_image_recipe_names = self.get_custom_image_recipe_names()
> > -        criteria = Q(is_image=True) &
> Q(name__in=custom_image_recipe_names)
> > -        return self.get_recipes().filter(criteria).order_by('name')
> > +        built_recipe_names = self.get_recipes().values_list('name',
> flat=True)
> > +        criteria = Q(name__in=built_recipe_names) &
> Q(project=self.project)
> > +        queryset =
> CustomImageRecipe.objects.filter(criteria).order_by('name')
> > +        return queryset
> >
> >      def get_outcome_text(self):
> >          return Build.BUILD_OUTCOME[int(self.outcome)][1]
> > @@ -1374,6 +1378,9 @@ class Layer(models.Model):
> >
> >  # LayerCommit class is synced with layerindex.LayerBranch
> >  class Layer_Version(models.Model):
> > +    """
> > +    A Layer_Version either belongs to a single project or no project
> > +    """
> >      search_allowed_fields = ["layer__name", "layer__summary",
> "layer__description",
> > "layer__vcs_url", "dirpath", "up_branch__name", "commit", "branch"]
> >      build = models.ForeignKey(Build,
> related_name='layer_version_build', default =
> > None, null = True)
> >      layer = models.ForeignKey(Layer, related_name='layer_version_layer')
> > diff --git a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> > b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> > index 8d1d20f..88caaff 100644
> > --- a/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> > +++ b/bitbake/lib/toaster/toastergui/static/js/libtoaster.js
> > @@ -344,6 +344,8 @@ var libtoaster = (function (){
> >    }
> >
> >    function _createCustomRecipe(name, baseRecipeId, doneCb){
> > +    debugger;
> > +
> >      var data = {
> >        'name' : name,
> >        'project' : libtoaster.ctx.projectId,
> > diff --git
> a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> > b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> > index 1ae0d34..a6d5b1a 100644
> > --- a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> > +++ b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> > @@ -12,6 +12,7 @@ for the new custom image. This will manage the
> addition of radio
> > buttons
> >  to select the base image (or remove the radio buttons, if there is only
> a
> >  single base image available).
> >  */
> > +
> >  function newCustomImageModalInit(){
> >
> >    var newCustomImgBtn = $("#create-new-custom-image-btn");
> > @@ -112,13 +113,13 @@ function
> newCustomImageModalSetRecipes(baseRecipes) {
> >    var imageSelector = $('#new-custom-image-modal
> [data-role="image-selector"]');
> >    var imageSelectRadiosContainer = $('#new-custom-image-modal
> [data-role="image-
> > selector-radios"]');
> >
> > +  // remove any existing radio buttons + labels
> > +  imageSelector.remove('[data-role="image-radio"]');
> > +
> >    if (baseRecipes.length === 1) {
> >      // hide the radio button container
> >      imageSelector.hide();
> >
> > -    // remove any radio buttons + labels
> > -    imageSelector.remove('[data-role="image-radio"]');
> > -
> >      // set the single recipe ID on the modal as it's the only one
> >      // we can build from
> >      imgCustomModal.data('recipe', baseRecipes[0].id);
> > diff --git a/bitbake/lib/toaster/toastergui/templates/base.html
> > b/bitbake/lib/toaster/toastergui/templates/base.html
> > index 192f9fb..210cf33 100644
> > --- a/bitbake/lib/toaster/toastergui/templates/base.html
> > +++ b/bitbake/lib/toaster/toastergui/templates/base.html
> > @@ -43,7 +43,6 @@
> >          recipesTypeAheadUrl: {% url 'xhr_recipestypeahead' project.id
> as
> > paturl%}{{paturl|json}},
> >          layersTypeAheadUrl: {% url 'xhr_layerstypeahead' project.id as
> > paturl%}{{paturl|json}},
> >          machinesTypeAheadUrl: {% url 'xhr_machinestypeahead' project.id
> as
> > paturl%}{{paturl|json}},
> > -
> >          projectBuildsUrl: {% url 'projectbuilds' project.id as pburl
> %}{{pburl|json}},
> >          xhrCustomRecipeUrl : "{% url 'xhr_customrecipe' %}",
> >          projectId : {{project.id}},
> > diff --git a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> > b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> > index 4a8e2a7..0d8c882 100644
> > --- a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> > +++ b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> > @@ -1,7 +1,7 @@
> >  {% extends "base.html" %}
> >  {% load projecttags %}
> >  {% load project_url_tag %}
> > -{% load queryset_to_list_filter %}
> > +{% load objects_to_dictionaries_filter %}
> >  {% load humanize %}
> >  {% block pagecontent %}
> >    <!-- breadcrumbs -->
> > @@ -81,33 +81,40 @@
> >            </p>
> >          </li>
> >
> > -        <li>
> > -          <!-- edit custom image built during this build -->
> > -          <p class="navbar-btn" data-role="edit-custom-image-trigger">
> > -            <button class="btn btn-block">Edit custom image</button>
> > -          </p>
> > -          {% include 'editcustomimage_modal.html' %}
> > -          <script>
> > -            $(document).ready(function () {
> > -              var editableCustomImageRecipes = {{
> build.get_custom_image_recipes |
> > queryset_to_list:"id,name" | json }};
> > -
> > -              // edit custom image which was built during this build
> > -              var editCustomImageModal = $('#edit-custom-image-modal');
> > -              var editCustomImageTrigger =
> $('[data-role="edit-custom-image-
> > trigger"]');
> > +        {% with build.get_custom_image_recipes as custom_image_recipes
> %}
> > +          {% if custom_image_recipes.count > 0 %}
> > +            <!-- edit custom image built during this build -->
> > +            <li>
> > +              <p class="navbar-btn"
> data-role="edit-custom-image-trigger">
> > +                <button class="btn btn-block">Edit custom image</button>
> > +                {% include 'editcustomimage_modal.html' %}
> > +                <script>
> > +                  var editableCustomImageRecipes = {{
> custom_image_recipes |
> > objects_to_dictionaries:"id,name" | json }};
> >
> > -              editCustomImageTrigger.click(function () {
> > -                // if there is a single editable custom image, go
> direct to the edit
> > -                // page for it; if there are multiple editable custom
> images, show
> > -                // dialog to select one of them for editing
> > +                  $(document).ready(function () {
> > +                    var editCustomImageTrigger =
> $('[data-role="edit-custom-image-
> > trigger"]');
> > +                    var editCustomImageModal =
> $('#edit-custom-image-modal');
> >
> > -                // single editable custom image
> > -
> > -                // multiple editable custom images
> > -                editCustomImageModal.modal('show');
> > -              });
> > -            });
> > -          </script>
> > -        </li>
> > +                    // edit custom image which was built during this
> build
> > +                    editCustomImageTrigger.click(function () {
> > +                      // single editable custom image: redirect to the
> edit page
> > +                      // for that image
> > +                      if (editableCustomImageRecipes.length === 1) {
> > +                        var url = '{% url "customrecipe"
> build.project.id
> > custom_image_recipes.first.id %}';
> > +                        document.location.href = url;
> > +                      }
> > +                      // multiple editable custom images: show modal to
> select
> > +                      // one of them for editing
> > +                      else {
> > +                        editCustomImageModal.modal('show');
> > +                      }
> > +                    });
> > +                  });
> > +                </script>
> > +              </p>
> > +            </li>
> > +          {% endif %}
> > +        {% endwith %}
> >
> >          <li>
> >            <!-- new custom image from image recipe in this build -->
> > @@ -119,7 +126,7 @@
> >              // imageRecipes includes both custom image recipes and
> built-in
> >              // image recipes, any of which can be used as the basis for
> a
> >              // new custom image
> > -            var imageRecipes = {{ build.get_image_recipes |
> queryset_to_list:"id,name"
> > | json }};
> > +            var imageRecipes = {{ build.get_image_recipes |
> > objects_to_dictionaries:"id,name" | json }};
> >
> >              $(document).ready(function () {
> >                var newCustomImageModal = $('#new-custom-image-modal');
> > @@ -131,6 +138,7 @@
> >                  if (!imageRecipes.length) {
> >                    return;
> >                  }
> > +
> >                  newCustomImageModalSetRecipes(imageRecipes);
> >                  newCustomImageModal.modal('show');
> >                });
> > diff --git
> a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> > b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> > index fd998f6..8046c08 100644
> > --- a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> > +++ b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> > @@ -1,23 +1,71 @@
> >  <!--
> > -modal dialog shown on the build dashboard, for editing an existing
> custom image
> > +modal dialog shown on the build dashboard, for editing an existing
> custom image;
> > +only shown if more than one custom image was built, so the user needs to
> > +choose which one to edit
> > +
> > +required context:
> > +  build - a Build object
> >  -->
> >  <div class="modal hide fade in" aria-hidden="false"
> id="edit-custom-image-modal">
> >    <div class="modal-header">
> >      <button type="button" class="close" data-dismiss="modal" aria-
> > hidden="true">×</button>
> > -    <h3>Select custom image to edit</h3>
> > +    <h3>Which image do you want to edit?</h3>
> >    </div>
> > +
> >    <div class="modal-body">
> >      <div class="row-fluid">
> > -      <span class="help-block">
> > -        Explanation of what this modal is for
> > -      </span>
> > -    </div>
> > -    <div class="control-group controls">
> > -      <input type="text" class="huge" placeholder="input box" required>
> > -      <span class="help-block error" style="display:none">Error
> text</span>
> > +      {% for recipe in build.get_custom_image_recipes %}
> > +        <label class="radio">
> > +          {{recipe.name}}
> > +          <input type="radio" class="form-control"
> name="select-custom-image"
> > +                 data-url="{% url 'customrecipe' build.project.id
> recipe.id %}">
> > +        </label>
> > +      {% endfor %}
> >      </div>
> > +    <span class="help-block error" id="invalid-custom-image-help"
> style="display:none">
> > +      Please select a custom image to edit.
> > +    </span>
> >    </div>
> > +
> >    <div class="modal-footer">
> > -    <button class="btn btn-primary btn-large" disabled>Action</button>
> > +    <button class="btn btn-primary btn-large" data-url="#"
> > +       data-action="edit-custom-image" disabled>
> > +      Edit custom image
> > +    </button>
> >    </div>
> >  </div>
> > +
> > +<script>
> > +$(document).ready(function () {
> > +  var editCustomImageButton = $('[data-action="edit-custom-image"]');
> > +  var error = $('#invalid-custom-image-help');
> > +  var radios = $('[name="select-custom-image"]');
> > +
> > +  // return custom image radio buttons which are selected
> > +  var getSelectedRadios = function () {
> > +    return $('[name="select-custom-image"]:checked');
> > +  };
> > +
> > +  radios.change(function () {
> > +    if (getSelectedRadios().length === 1) {
> > +      editCustomImageButton.removeAttr('disabled');
> > +      error.hide();
> > +    }
> > +    else {
> > +      editCustomImageButton.attr('disabled', 'disabled');
> > +      error.show();
> > +    }
> > +  });
> > +
> > +  editCustomImageButton.click(function () {
> > +    var selectedRadios = getSelectedRadios();
> > +
> > +    if (selectedRadios.length === 1) {
> > +      document.location.href = selectedRadios.first().attr('data-url');
> > +    }
> > +    else {
> > +      error.show();
> > +    }
> > +  });
> > +});
> > +</script>
> > diff --git
> >
> a/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
> >
> b/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
> > new file mode 100644
> > index 0000000..0dcc7d2
> > --- /dev/null
> > +++
> b/bitbake/lib/toaster/toastergui/templatetags/objects_to_dictionaries_filter.py
> > @@ -0,0 +1,35 @@
> > +from django import template
> > +import json
> > +
> > +register = template.Library()
> > +
> > +def objects_to_dictionaries(iterable, fields):
> > +    """
> > +    Convert an iterable into a list of dictionaries; fields should be
> set
> > +    to a comma-separated string of properties for each item included in
> the
> > +    resulting list; e.g. for a queryset:
> > +
> > +        {{ queryset | objects_to_dictionaries:"id,name" }}
> > +
> > +    will return a list like
> > +
> > +        [{'id': 1, 'name': 'foo'}, ...]
> > +
> > +    providing queryset has id and name fields
> > +
> > +    This is mostly to support serialising querysets or lists of model
> objects
> > +    to JSON
> > +    """
> > +    objects = []
> > +
> > +    if fields:
> > +        fields_list = [field.strip() for field in fields.split(',')]
> > +        for item in iterable:
> > +            out = {}
> > +            for field in fields_list:
> > +                out[field] = getattr(item, field)
> > +            objects.append(out)
> > +
> > +    return objects
> > +
> > +register.filter('objects_to_dictionaries', objects_to_dictionaries)
> > diff --git
> a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> > b/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> > deleted file mode 100644
> > index dfc094b..0000000
> > ---
> a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> > +++ /dev/null
> > @@ -1,26 +0,0 @@
> > -from django import template
> > -import json
> > -
> > -register = template.Library()
> > -
> > -def queryset_to_list(queryset, fields):
> > -    """
> > -    Convert a queryset to a list; fields can be set to a comma-separated
> > -    string of fields for each record included in the resulting list; if
> > -    omitted, all fields are included for each record, e.g.
> > -
> > -        {{ queryset | queryset_to_list:"id,name" }}
> > -
> > -    will return a list like
> > -
> > -        [{'id': 1, 'name': 'foo'}, ...]
> > -
> > -    (providing queryset has id and name fields)
> > -    """
> > -    if fields:
> > -        fields_list = [field.strip() for field in fields.split(',')]
> > -        return list(queryset.values(*fields_list))
> > -    else:
> > -        return list(queryset.values())
> > -
> > -register.filter('queryset_to_list', queryset_to_list)
> > diff --git a/bitbake/lib/toaster/toastergui/views.py
> > b/bitbake/lib/toaster/toastergui/views.py
> > index 60edb45..1f824ee 100755
> > --- a/bitbake/lib/toaster/toastergui/views.py
> > +++ b/bitbake/lib/toaster/toastergui/views.py
> > @@ -507,6 +507,7 @@ def builddashboard( request, build_id ):
> >
> >      context = {
> >              'build'           : build,
> > +            'project'         : build.project,
> >              'hasImages'       : hasImages,
> >              'ntargets'        : ntargets,
> >              'targets'         : targets,
> > @@ -797,6 +798,7 @@ eans multiple licenses exist that cover different
> parts of the
> > source',
> >      context = {
> >          'objectname': variant,
> >          'build'                : build,
> > +        'project'              : build.project,
> >          'target'               : Target.objects.filter( pk = target_id
> )[ 0 ],
> >          'objects'              : packages,
> >          'packages_sum'         : packages_sum[ 'installed_size__sum' ],
> > @@ -937,7 +939,10 @@ def dirinfo(request, build_id, target_id,
> file_path=None):
> >              if head != sep:
> >                  dir_list.insert(0, head)
> >
> > -    context = { 'build': Build.objects.get(pk=build_id),
> > +    build = Build.objects.get(pk=build_id)
> > +
> > +    context = { 'build': build,
> > +                'project': build.project,
> >                  'target': Target.objects.get(pk=target_id),
> >                  'packages_sum': packages_sum['installed_size__sum'],
> >                  'objects': objects,
> > @@ -1211,6 +1216,7 @@ def tasks_common(request, build_id, variant,
> task_anchor):
> >                  'filter_search_display': filter_search_display,
> >                  'mainheading': title_variant,
> >                  'build': build,
> > +                'project': build.project,
> >                  'objects': task_objects,
> >                  'default_orderby' : orderby,
> >                  'search_term': search_term,
> > @@ -1282,6 +1288,7 @@ def recipes(request, build_id):
> >      context = {
> >          'objectname': 'recipes',
> >          'build': build,
> > +        'project': build.project,
> >          'objects': recipes,
> >          'default_orderby' : 'name:+',
> >          'recipe_deps' : deps,
> > @@ -1366,10 +1373,12 @@ def configuration(request, build_id):
> >                   'MACHINE', 'DISTRO', 'DISTRO_VERSION',
> 'TUNE_FEATURES', 'TARGET_FPU')
> >      context = dict(Variable.objects.filter(build=build_id,
> > variable_name__in=var_names)\
> >                                             .values_list('variable_name',
> > 'variable_value'))
> > +    build = Build.objects.get(pk=build_id)
> >      context.update({'objectname': 'configuration',
> >                      'object_search_display':'variables',
> >                      'filter_search_display':'variables',
> > -                    'build': Build.objects.get(pk=build_id),
> > +                    'build': build,
> > +                    'project': build.project,
> >                      'targets': Target.objects.filter(build=build_id)})
> >      return render(request, template, context)
> >
> > @@ -1406,12 +1415,15 @@ def configvars(request, build_id):
> >          file_filter += '/bitbake.conf'
> >
> build_dir=re.sub("/tmp/log/.*","",Build.objects.get(pk=build_id).cooker_log_path)
> >
> > +    build = Build.objects.get(pk=build_id)
> > +
> >      context = {
> >                  'objectname': 'configvars',
> >                  'object_search_display':'BitBake variables',
> >                  'filter_search_display':'variables',
> >                  'file_filter': file_filter,
> > -                'build': Build.objects.get(pk=build_id),
> > +                'build': build,
> > +                'project': build.project,
> >                  'objects' : variables,
> >                  'total_count':queryset_with_search.count(),
> >                  'default_orderby' : 'variable_name:+',
> > @@ -1480,6 +1492,7 @@ def bpackage(request, build_id):
> >      context = {
> >          'objectname': 'packages built',
> >          'build': build,
> > +        'project': build.project,
> >          'objects' : packages,
> >          'default_orderby' : 'name:+',
> >          'tablecols':[
> > @@ -1554,7 +1567,12 @@ def bpackage(request, build_id):
> >  def bfile(request, build_id, package_id):
> >      template = 'bfile.html'
> >      files = Package_File.objects.filter(package = package_id)
> > -    context = {'build': Build.objects.get(pk=build_id), 'objects' :
> files}
> > +    build = Build.objects.get(pk=build_id)
> > +    context = {
> > +        'build': build,
> > +        'project': build.project,
> > +        'objects' : files
> > +    }
> >      return render(request, template, context)
> >
> >
> > --
> > 1.9.3
> >
> > ---------------------------------------------------------------------
> > 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.
> > --
> > _______________________________________________
> > toaster mailing list
> > toaster@yoctoproject.org
> > https://lists.yoctoproject.org/listinfo/toaster
>



-- 
Elliot Smith
Software Engineer
Intel Open Source Technology Centre

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

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

* Re: [PATCH 1/3] toaster: add build dashboard buttons to edit/create custom images
  2016-04-11 14:56 ` [PATCH 1/3] toaster: add build dashboard buttons to edit/create custom images Elliot Smith
@ 2016-04-19 16:33   ` Michael Wood
  0 siblings, 0 replies; 7+ messages in thread
From: Michael Wood @ 2016-04-19 16:33 UTC (permalink / raw)
  To: toaster

Sent to bitbake-devel and added to toaster-next

Thanks,

Michael

On 11/04/16 15:56, Elliot Smith wrote:
> When a build is viewed in the dashboard, enable users to edit
> a custom image which was built during that build, and/or create
> a new custom image based on one of the image recipes built during
> the build.
>
> Add methods to the Build model to enable querying for the
> set of image recipes built during a build.
>
> Add buttons to the dashboard, with the "Edit custom image"
> button opening a basic modal for now. The "New custom image"
> button opens the existing new custom image modal, but is modified
> to show a list of images available as a base for a new custom image.
>
> Add a new function to the new custom image modal's script which
> enables multiple potential custom images to be shown as radio
> buttons in the dialog (if there is more than 1). Modify existing
> code to use this new function.
>
> Add a template filter which allows the queryset of recipes for
> a build to be available to client-side scripts, and from there
> be used to populate the new custom image modal.
>
> [YOCTO #9123]
>
> Signed-off-by: Elliot Smith <elliot.smith@intel.com>
> ---
>   bitbake/lib/toaster/orm/models.py                  |  41 ++++
>   .../lib/toaster/toastergui/static/js/layerBtn.js   |   3 +-
>   .../toastergui/static/js/newcustomimage_modal.js   |  97 +++++++++-
>   .../toaster/toastergui/static/js/recipedetails.js  |   3 +-
>   .../toastergui/templates/basebuildpage.html        | 207 +++++++++++++--------
>   .../templates/editcustomimage_modal.html           |  23 +++
>   .../toastergui/templates/newcustomimage_modal.html |  28 ++-
>   .../templatetags/queryset_to_list_filter.py        |  26 +++
>   bitbake/lib/toaster/toastergui/views.py            |   7 +-
>   9 files changed, 344 insertions(+), 91 deletions(-)
>   create mode 100644 bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
>   create mode 100644 bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
>
> diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py
> index 68c3072..c63d631 100644
> --- a/bitbake/lib/toaster/orm/models.py
> +++ b/bitbake/lib/toaster/orm/models.py
> @@ -484,6 +484,47 @@ class Build(models.Model):
>           tgts = Target.objects.filter(build_id = self.id).order_by( 'target' );
>           return( tgts );
>   
> +    def get_recipes(self):
> +        """
> +        Get the recipes related to this build;
> +        note that the related layer versions and layers are also prefetched
> +        by this query, as this queryset can be sorted by these objects in the
> +        build recipes view; prefetching them here removes the need
> +        for another query in that view
> +        """
> +        layer_versions = Layer_Version.objects.filter(build=self)
> +        criteria = Q(layer_version__id__in=layer_versions)
> +        return Recipe.objects.filter(criteria) \
> +                             .select_related('layer_version', 'layer_version__layer')
> +
> +    def get_custom_image_recipe_names(self):
> +        """
> +        Get the names of custom image recipes for this build's project
> +        as a list; this is used to screen out custom image recipes from the
> +        recipes for the build by name, and to distinguish image recipes from
> +        custom image recipes
> +        """
> +        custom_image_recipes = \
> +            CustomImageRecipe.objects.filter(project=self.project)
> +        return custom_image_recipes.values_list('name', flat=True)
> +
> +    def get_image_recipes(self):
> +        """
> +        Returns a queryset of image recipes related to this build, sorted
> +        by name
> +        """
> +        criteria = Q(is_image=True)
> +        return self.get_recipes().filter(criteria).order_by('name')
> +
> +    def get_custom_image_recipes(self):
> +        """
> +        Returns a queryset of custom image recipes related to this build,
> +        sorted by name
> +        """
> +        custom_image_recipe_names = self.get_custom_image_recipe_names()
> +        criteria = Q(is_image=True) & Q(name__in=custom_image_recipe_names)
> +        return self.get_recipes().filter(criteria).order_by('name')
> +
>       def get_outcome_text(self):
>           return Build.BUILD_OUTCOME[int(self.outcome)][1]
>   
> diff --git a/bitbake/lib/toaster/toastergui/static/js/layerBtn.js b/bitbake/lib/toaster/toastergui/static/js/layerBtn.js
> index aa43284..259271d 100644
> --- a/bitbake/lib/toaster/toastergui/static/js/layerBtn.js
> +++ b/bitbake/lib/toaster/toastergui/static/js/layerBtn.js
> @@ -76,7 +76,8 @@ function layerBtnsInit() {
>       if (imgCustomModal.length == 0)
>         throw("Modal new-custom-image not found");
>   
> -    imgCustomModal.data('recipe', $(this).data('recipe'));
> +    var recipe = {id: $(this).data('recipe'), name: null}
> +    newCustomImageModalSetRecipes([recipe]);
>       imgCustomModal.modal('show');
>     });
>   }
> diff --git a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> index 328997a..1ae0d34 100644
> --- a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> +++ b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js
> @@ -1,29 +1,59 @@
>   "use strict";
>   
> -/* Used for the newcustomimage_modal actions */
> +/*
> +Used for the newcustomimage_modal actions
> +
> +The .data('recipe') value on the outer element determines which
> +recipe ID is used as the basis for the new custom image recipe created via
> +this modal.
> +
> +Use newCustomImageModalSetRecipes() to set the recipes available as a base
> +for the new custom image. This will manage the addition of radio buttons
> +to select the base image (or remove the radio buttons, if there is only a
> +single base image available).
> +*/
>   function newCustomImageModalInit(){
>   
>     var newCustomImgBtn = $("#create-new-custom-image-btn");
>     var imgCustomModal = $("#new-custom-image-modal");
>     var invalidNameHelp = $("#invalid-name-help");
> +  var invalidRecipeHelp = $("#invalid-recipe-help");
>     var nameInput = imgCustomModal.find('input');
>   
> -  var invalidMsg = "Image names cannot contain spaces or capital letters. The only allowed special character is dash (-).";
> +  var invalidNameMsg = "Image names cannot contain spaces or capital letters. The only allowed special character is dash (-).";
> +  var duplicateNameMsg = "An image with this name already exists. Image names must be unique.";
> +  var invalidBaseRecipeIdMsg = "Please select an image to customise.";
> +
> +  // capture clicks on radio buttons inside the modal; when one is selected,
> +  // set the recipe on the modal
> +  imgCustomModal.on("click", "[name='select-image']", function (e) {
> +    clearRecipeError();
> +
> +    var recipeId = $(e.target).attr('data-recipe');
> +    imgCustomModal.data('recipe', recipeId);
> +  });
>   
>     newCustomImgBtn.click(function(e){
>       e.preventDefault();
>   
>       var baseRecipeId = imgCustomModal.data('recipe');
>   
> +    if (!baseRecipeId) {
> +      showRecipeError(invalidBaseRecipeIdMsg);
> +      return;
> +    }
> +
>       if (nameInput.val().length > 0) {
>         libtoaster.createCustomRecipe(nameInput.val(), baseRecipeId,
>         function(ret) {
>           if (ret.error !== "ok") {
>             console.warn(ret.error);
>             if (ret.error === "invalid-name") {
> -            showError(invalidMsg);
> +            showNameError(invalidNameMsg);
> +            return;
>             } else if (ret.error === "already-exists") {
> -            showError("An image with this name already exists. Image names must be unique.");
> +            showNameError(duplicateNameMsg);
> +            return;
>             }
>           } else {
>             imgCustomModal.modal('hide');
> @@ -33,12 +63,21 @@ function newCustomImageModalInit(){
>       }
>     });
>   
> -  function showError(text){
> +  function showNameError(text){
>       invalidNameHelp.text(text);
>       invalidNameHelp.show();
>       nameInput.parent().addClass('error');
>     }
>   
> +  function showRecipeError(text){
> +    invalidRecipeHelp.text(text);
> +    invalidRecipeHelp.show();
> +  }
> +
> +  function clearRecipeError(){
> +    invalidRecipeHelp.hide();
> +  }
> +
>     nameInput.on('keyup', function(){
>       if (nameInput.val().length === 0){
>         newCustomImgBtn.prop("disabled", true);
> @@ -46,7 +85,7 @@ function newCustomImageModalInit(){
>       }
>   
>       if (nameInput.val().search(/[^a-z|0-9|-]/) != -1){
> -      showError(invalidMsg);
> +      showNameError(invalidNameMsg);
>         newCustomImgBtn.prop("disabled", true);
>         nameInput.parent().addClass('error');
>       } else {
> @@ -56,3 +95,49 @@ function newCustomImageModalInit(){
>       }
>     });
>   }
> +
> +// Set the image recipes which can used as the basis for the custom
> +// image recipe the user is creating
> +//
> +// baseRecipes: a list of one or more recipes which can be
> +// used as the base for the new custom image recipe in the format:
> +// [{'id': <recipe ID>, 'name': <recipe name>'}, ...]
> +//
> +// if recipes is a single recipe, just show the text box to set the
> +// name for the new custom image; if recipes contains multiple recipe objects,
> +// show a set of radio buttons so the user can decide which to use as the
> +// basis for the new custom image
> +function newCustomImageModalSetRecipes(baseRecipes) {
> +  var imgCustomModal = $("#new-custom-image-modal");
> +  var imageSelector = $('#new-custom-image-modal [data-role="image-selector"]');
> +  var imageSelectRadiosContainer = $('#new-custom-image-modal [data-role="image-selector-radios"]');
> +
> +  if (baseRecipes.length === 1) {
> +    // hide the radio button container
> +    imageSelector.hide();
> +
> +    // remove any radio buttons + labels
> +    imageSelector.remove('[data-role="image-radio"]');
> +
> +    // set the single recipe ID on the modal as it's the only one
> +    // we can build from
> +    imgCustomModal.data('recipe', baseRecipes[0].id);
> +  }
> +  else {
> +    // add radio buttons; note that the handlers for the radio buttons
> +    // are set in newCustomImageModalInit via event delegation
> +    for (var i = 0; i < baseRecipes.length; i++) {
> +      var recipe = baseRecipes[i];
> +      imageSelectRadiosContainer.append(
> +        '<label class="radio" data-role="image-radio">' +
> +        recipe.name +
> +        '<input type="radio" class="form-control" name="select-image" ' +
> +        'data-recipe="' + recipe.id + '">' +
> +        '</label>'
> +      );
> +    }
> +
> +    // show the radio button container
> +    imageSelector.show();
> +  }
> +}
> diff --git a/bitbake/lib/toaster/toastergui/static/js/recipedetails.js b/bitbake/lib/toaster/toastergui/static/js/recipedetails.js
> index d5f9eac..604db5f 100644
> --- a/bitbake/lib/toaster/toastergui/static/js/recipedetails.js
> +++ b/bitbake/lib/toaster/toastergui/static/js/recipedetails.js
> @@ -9,7 +9,8 @@ function recipeDetailsPageInit(ctx){
>       if (imgCustomModal.length === 0)
>         throw("Modal new-custom-image not found");
>   
> -    imgCustomModal.data('recipe', $(this).data('recipe'));
> +    var recipe = {id: $(this).data('recipe'), name: null}
> +    newCustomImageModalSetRecipes([recipe]);
>       imgCustomModal.modal('show');
>     });
>   
> diff --git a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> index ff9433e..4a8e2a7 100644
> --- a/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> +++ b/bitbake/lib/toaster/toastergui/templates/basebuildpage.html
> @@ -1,90 +1,149 @@
>   {% extends "base.html" %}
>   {% load projecttags %}
>   {% load project_url_tag %}
> +{% load queryset_to_list_filter %}
>   {% load humanize %}
>   {% block pagecontent %}
> +  <!-- breadcrumbs -->
> +  <div class="section">
> +    <ul class="breadcrumb" id="breadcrumb">
> +      <li><a href="{% project_url build.project %}">{{build.project.name}}</a></li>
> +      {% if not build.project.is_default %}
> +        <li><a href="{% url 'projectbuilds' build.project.id %}">Builds</a></li>
> +      {% endif %}
> +      <li>
> +        {% block parentbreadcrumb %}
> +          <a href="{%url 'builddashboard' build.pk%}">
> +            {{build.get_sorted_target_list.0.target}} {% if build.target_set.all.count > 1 %}(+{{build.target_set.all.count|add:"-1"}}){% endif %} {{build.machine}} ({{build.completed_on|date:"d/m/y H:i"}})
> +          </a>
> +        {% endblock %}
> +      </li>
> +      {% block localbreadcrumb %}{% endblock %}
> +    </ul>
> +    <script>
> +      $( function () {
> +        $('#breadcrumb > li').append('<span class="divider">&rarr;</span>');
> +        $('#breadcrumb > li:last').addClass("active");
> +        $('#breadcrumb > li:last > span').remove();
> +      });
> +    </script>
> +  </div>
> +
> +  <div class="row-fluid">
> +    <!-- begin left sidebar container -->
> +    <div id="nav" class="span2">
> +      <ul class="nav nav-list well">
> +        <li
> +          {% if request.resolver_match.url_name == 'builddashboard'  %}
> +            class="active"
> +          {% endif %} >
> +          <a class="nav-parent" href="{% url 'builddashboard' build.pk %}">Build summary</a>
> +        </li>
> +        {% if build.target_set.all.0.is_image and build.outcome == 0 %}
> +          <li class="nav-header">Images</li>
> +          {% block nav-target %}
> +            {% for t in build.get_sorted_target_list %}
> +              <li><a href="{% url 'target' build.pk t.pk %}">{{t.target}}</a><li>
> +            {% endfor %}
> +          {% endblock %}
> +        {% endif %}
> +        <li class="nav-header">Build</li>
> +        {% block nav-configuration %}
> +          <li><a href="{% url 'configuration' build.pk %}">Configuration</a></li>
> +        {% endblock %}
> +        {% block nav-tasks %}
> +          <li><a href="{% url 'tasks' build.pk %}">Tasks</a></li>
> +        {% endblock %}
> +        {% block nav-recipes %}
> +          <li><a href="{% url 'recipes' build.pk %}">Recipes</a></li>
> +        {% endblock %}
> +        {% block nav-packages %}
> +          <li><a href="{% url 'packages' build.pk %}">Packages</a></li>
> +        {% endblock %}
> +          <li class="nav-header">Performance</li>
> +        {% block nav-buildtime %}
> +          <li><a href="{% url 'buildtime' build.pk %}">Time</a></li>
> +        {% endblock %}
> +        {% block nav-cputime %}
> +          <li><a href="{% url 'cputime' build.pk %}">CPU usage</a></li>
> +        {% endblock %}
> +        {% block nav-diskio %}
> +          <li><a href="{% url 'diskio' build.pk %}">Disk I/O</a></li>
> +        {% endblock %}
>   
> +        <li class="divider"></li>
>   
> - <div class="">
> -<!-- Breadcrumbs -->
> -    <div class="section">
> -        <ul class="breadcrumb" id="breadcrumb">
> -            <li><a href="{% project_url build.project %}">{{build.project.name}}</a></li>
> -            {% if not build.project.is_default %}
> -                <li><a href="{% url 'projectbuilds' build.project.id %}">Builds</a></li>
> -            {% endif %}
> -            <li>
> -            {% block parentbreadcrumb %}
> -            <a href="{%url 'builddashboard' build.pk%}">
> -              {{build.get_sorted_target_list.0.target}} {%if build.target_set.all.count > 1%}(+{{build.target_set.all.count|add:"-1"}}){%endif%} {{build.machine}} ({{build.completed_on|date:"d/m/y H:i"}})
> +        <li>
> +          <p class="navbar-btn">
> +            <a class="btn btn-block" href="{% url 'build_artifact' build.id 'cookerlog' build.id %}">
> +              Download build log
>               </a>
> -            {% endblock %}
> -            </li>
> -            {% block localbreadcrumb %}{% endblock %}
> -        </ul>
> -        <script>
> -        $( function () {
> -            $('#breadcrumb > li').append('<span class="divider">&rarr;</span>');
> -            $('#breadcrumb > li:last').addClass("active");
> -            $('#breadcrumb > li:last > span').remove();
> -        });
> -        </script>
> -    </div>
> +          </p>
> +        </li>
>   
> -    <div class="row-fluid">
> +        <li>
> +          <!-- edit custom image built during this build -->
> +          <p class="navbar-btn" data-role="edit-custom-image-trigger">
> +            <button class="btn btn-block">Edit custom image</button>
> +          </p>
> +          {% include 'editcustomimage_modal.html' %}
> +          <script>
> +            $(document).ready(function () {
> +              var editableCustomImageRecipes = {{ build.get_custom_image_recipes | queryset_to_list:"id,name" | json }};
>   
> -        <!-- begin left sidebar container -->
> -        <div id="nav" class="span2">
> -            <ul class="nav nav-list well">
> -              <li
> -                {% if request.resolver_match.url_name == 'builddashboard'  %}
> -                  class="active"
> -                {% endif %} >
> -                <a class="nav-parent" href="{% url 'builddashboard' build.pk %}">Build summary</a>
> -              </li>
> -              {% if build.target_set.all.0.is_image and build.outcome == 0 %}
> -                <li class="nav-header">Images</li>
> -                {% block nav-target %}
> -                  {% for t in build.get_sorted_target_list %}
> -                    <li><a href="{% url 'target' build.pk t.pk %}">{{t.target}}</a><li>
> -                  {% endfor %}
> -                {% endblock %}
> -              {% endif %}
> -              <li class="nav-header">Build</li>
> -              {% block nav-configuration %}
> -                  <li><a href="{% url 'configuration' build.pk %}">Configuration</a></li>
> -              {% endblock %}
> -              {% block nav-tasks %}
> -                  <li><a href="{% url 'tasks' build.pk %}">Tasks</a></li>
> -              {% endblock %}
> -              {% block nav-recipes %}
> -                  <li><a href="{% url 'recipes' build.pk %}">Recipes</a></li>
> -              {% endblock %}
> -              {% block nav-packages %}
> -                  <li><a href="{% url 'packages' build.pk %}">Packages</a></li>
> -              {% endblock %}
> -                  <li class="nav-header">Performance</li>
> -              {% block nav-buildtime %}
> -                  <li><a href="{% url 'buildtime' build.pk %}">Time</a></li>
> -              {% endblock %}
> -              {% block nav-cputime %}
> -                  <li><a href="{% url 'cputime' build.pk %}">CPU time</a></li>
> -              {% endblock %}
> -              {% block nav-diskio %}
> -                  <li><a href="{% url 'diskio' build.pk %}">Disk I/O</a></li>
> -              {% endblock %}
> -            </ul>
> -        </div>
> -        <!-- end left sidebar container -->
> +              // edit custom image which was built during this build
> +              var editCustomImageModal = $('#edit-custom-image-modal');
> +              var editCustomImageTrigger = $('[data-role="edit-custom-image-trigger"]');
>   
> -        <!-- Begin right container -->
> -        {% block buildinfomain %}{% endblock %}
> -        <!-- End right container -->
> +              editCustomImageTrigger.click(function () {
> +                // if there is a single editable custom image, go direct to the edit
> +                // page for it; if there are multiple editable custom images, show
> +                // dialog to select one of them for editing
>   
> +                // single editable custom image
>   
> -    </div>
> -  </div>
> +                // multiple editable custom images
> +                editCustomImageModal.modal('show');
> +              });
> +            });
> +          </script>
> +        </li>
>   
> +        <li>
> +          <!-- new custom image from image recipe in this build -->
> +          <p class="navbar-btn" data-role="new-custom-image-trigger">
> +            <button class="btn btn-block">New custom image</button>
> +          </p>
> +          {% include 'newcustomimage_modal.html' %}
> +          <script>
> +            // imageRecipes includes both custom image recipes and built-in
> +            // image recipes, any of which can be used as the basis for a
> +            // new custom image
> +            var imageRecipes = {{ build.get_image_recipes | queryset_to_list:"id,name" | json }};
>   
> -{% endblock %}
> +            $(document).ready(function () {
> +              var newCustomImageModal = $('#new-custom-image-modal');
> +              var newCustomImageTrigger = $('[data-role="new-custom-image-trigger"]');
>   
> +              // show create new custom image modal to select an image built
> +              // during this build as the basis for the custom recipe
> +              newCustomImageTrigger.click(function () {
> +                if (!imageRecipes.length) {
> +                  return;
> +                }
> +                newCustomImageModalSetRecipes(imageRecipes);
> +                newCustomImageModal.modal('show');
> +              });
> +            });
> +          </script>
> +        </li>
> +      </ul>
> +
> +    </div>
> +    <!-- end left sidebar container -->
> +
> +    <!-- begin right container -->
> +    {% block buildinfomain %}{% endblock %}
> +    <!-- end right container -->
> +  </div>
> +{% endblock %}
> diff --git a/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> new file mode 100644
> index 0000000..fd998f6
> --- /dev/null
> +++ b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html
> @@ -0,0 +1,23 @@
> +<!--
> +modal dialog shown on the build dashboard, for editing an existing custom image
> +-->
> +<div class="modal hide fade in" aria-hidden="false" id="edit-custom-image-modal">
> +  <div class="modal-header">
> +    <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
> +    <h3>Select custom image to edit</h3>
> +  </div>
> +  <div class="modal-body">
> +    <div class="row-fluid">
> +      <span class="help-block">
> +        Explanation of what this modal is for
> +      </span>
> +    </div>
> +    <div class="control-group controls">
> +      <input type="text" class="huge" placeholder="input box" required>
> +      <span class="help-block error" style="display:none">Error text</span>
> +    </div>
> +  </div>
> +  <div class="modal-footer">
> +    <button class="btn btn-primary btn-large" disabled>Action</button>
> +  </div>
> +</div>
> diff --git a/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html b/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html
> index b1b5148..caeb302 100644
> --- a/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html
> +++ b/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html
> @@ -15,18 +15,34 @@
>   <div class="modal hide fade in" id="new-custom-image-modal" aria-hidden="false">
>     <div class="modal-header">
>       <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
> -    <h3>Name your custom image</h3>
> +    <h3>New custom image</h3>
>     </div>
> +
>     <div class="modal-body">
> +    <!--
> +    this container is visible if there are multiple image recipes which could
> +    be used as a basis for the new custom image; radio buttons are added to it
> +    via newCustomImageModalSetRecipes() as required
> +    -->
> +    <div data-role="image-selector" style="display:none;">
> +      <h4>Which image do you want to customise?</h4>
> +      <div data-role="image-selector-radios"></div>
> +      <span class="help-block error" id="invalid-recipe-help" style="display:none"></span>
> +      <div class="air"></div>
> +    </div>
> +
> +    <h4>Name your custom image</h4>
> +
>       <div class="row-fluid">
>         <span class="help-block span8">Image names must be unique. They should not contain spaces or capital letters, and the only allowed special character is dash (-).<p></p>
>         </span></div>
>       <div class="control-group controls">
>         <input type="text" class="huge" placeholder="Type the custom image name" required>
> -        <span class="help-block error" id="invalid-name-help" style="display:none"></span>
> -      </div>
> -    </div>
> -    <div class="modal-footer">
> -      <button id="create-new-custom-image-btn" class="btn btn-primary btn-large" data-original-title="" title="" disabled>Create custom image</button>
> +      <span class="help-block error" id="invalid-name-help" style="display:none"></span>
>       </div>
> +  </div>
> +
> +  <div class="modal-footer">
> +    <button id="create-new-custom-image-btn" class="btn btn-primary btn-large" data-original-title="" title="" disabled>Create custom image</button>
> +  </div>
>   </div>
> diff --git a/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py b/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> new file mode 100644
> index 0000000..dfc094b
> --- /dev/null
> +++ b/bitbake/lib/toaster/toastergui/templatetags/queryset_to_list_filter.py
> @@ -0,0 +1,26 @@
> +from django import template
> +import json
> +
> +register = template.Library()
> +
> +def queryset_to_list(queryset, fields):
> +    """
> +    Convert a queryset to a list; fields can be set to a comma-separated
> +    string of fields for each record included in the resulting list; if
> +    omitted, all fields are included for each record, e.g.
> +
> +        {{ queryset | queryset_to_list:"id,name" }}
> +
> +    will return a list like
> +
> +        [{'id': 1, 'name': 'foo'}, ...]
> +
> +    (providing queryset has id and name fields)
> +    """
> +    if fields:
> +        fields_list = [field.strip() for field in fields.split(',')]
> +        return list(queryset.values(*fields_list))
> +    else:
> +        return list(queryset.values())
> +
> +register.filter('queryset_to_list', queryset_to_list)
> diff --git a/bitbake/lib/toaster/toastergui/views.py b/bitbake/lib/toaster/toastergui/views.py
> index 30295a7..60edb45 100755
> --- a/bitbake/lib/toaster/toastergui/views.py
> +++ b/bitbake/lib/toaster/toastergui/views.py
> @@ -1257,7 +1257,10 @@ def recipes(request, build_id):
>       if retval:
>           return _redirect_parameters( 'recipes', request.GET, mandatory_parameters, build_id = build_id)
>       (filter_string, search_term, ordering_string) = _search_tuple(request, Recipe)
> -    queryset = Recipe.objects.filter(layer_version__id__in=Layer_Version.objects.filter(build=build_id)).select_related("layer_version", "layer_version__layer")
> +
> +    build = Build.objects.get(pk=build_id)
> +
> +    queryset = build.get_recipes()
>       queryset = _get_queryset(Recipe, queryset, filter_string, search_term, ordering_string, 'name')
>   
>       recipes = _build_page_range(Paginator(queryset, pagesize),request.GET.get('page', 1))
> @@ -1276,8 +1279,6 @@ def recipes(request, build_id):
>               revlist.append(recipe_dep)
>           revs[recipe.id] = revlist
>   
> -    build = Build.objects.get(pk=build_id)
> -
>       context = {
>           'objectname': 'recipes',
>           'build': build,



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

end of thread, other threads:[~2016-04-19 16:34 UTC | newest]

Thread overview: 7+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2016-04-11 14:56 [PATCH 0/3] Add buttons to build dashboard to customise/edit images Elliot Smith
2016-04-11 14:56 ` [PATCH 1/3] toaster: add build dashboard buttons to edit/create custom images Elliot Smith
2016-04-19 16:33   ` Michael Wood
2016-04-11 14:56 ` [PATCH 2/3] toaster: add modal to select custom image for editing Elliot Smith
2016-04-13 17:11   ` Lerner, Dave
2016-04-14  7:27     ` Smith, Elliot
2016-04-11 14:56 ` [PATCH 3/3] toaster-tests: tests for build dashboard Elliot Smith

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.