diff --git a/bitbake/lib/toaster/orm/models.py b/bitbake/lib/toaster/orm/models.py index f0a8786640..75e6ea3996 100644 --- a/bitbake/lib/toaster/orm/models.py +++ b/bitbake/lib/toaster/orm/models.py @@ -490,6 +490,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 aa43284396..259271df33 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 98e87f4a6b..1ae0d34e90 100644 --- a/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js +++ b/bitbake/lib/toaster/toastergui/static/js/newcustomimage_modal.js @@ -1,33 +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 duplicateImageMsg = "An image with this name already exists in this project."; - var duplicateRecipeMsg = "A non-image recipe with this name already exists."; + 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); - } else if (ret.error === "image-already-exists") { - showError(duplicateImageMsg); - } else if (ret.error === "recipe-already-exists") { - showError(duplicateRecipeMsg); + showNameError(invalidNameMsg); + return; + } else if (ret.error === "already-exists") { + showNameError(duplicateNameMsg); + return; } } else { imgCustomModal.modal('hide'); @@ -37,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); @@ -50,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 { @@ -60,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': , '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( + '' + ); + } + + // 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 d5f9eacdce..604db5f037 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 ff9433eee7..4a8e2a7abd 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 %} - - -
- -
- - -
- -
- - - - - - - {% block buildinfomain %}{% endblock %} - - - -
+ +
+ +
+
+ + + + + + {% block buildinfomain %}{% endblock %} + +
{% 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 0000000000..fd998f63eb --- /dev/null +++ b/bitbake/lib/toaster/toastergui/templates/editcustomimage_modal.html @@ -0,0 +1,23 @@ + + diff --git a/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html b/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html index b1b5148c08..caeb302352 100644 --- a/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html +++ b/bitbake/lib/toaster/toastergui/templates/newcustomimage_modal.html @@ -15,18 +15,34 @@ + +
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 0000000000..dfc094b591 --- /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 9744f4efaf..942dc31ae9 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,