diff mbox series

[v2,5/5] toaster: Add recipe variables view on UI

Message ID 20260814153602.2107764-6-paolo.wattebled@savoirfairelinux.com
State New
Headers show
Series toaster: Add per-recipe variable view | expand

Commit Message

Paolo Wattebled Aug. 14, 2026, 3:35 p.m. UTC
Expose build-specific recipe variable snapshots through a searchable and
paginated Variables tab. Show snapshot counts in the built recipes table
and recipe package navigation.

Cover build and recipe scoping, pagination, ordering, escaping, empty
states and query parameter validation with view tests.

AI-Generated: Uses GitHub Copilot and OpenCode with GPT-5.6 Sol
Signed-off-by: Paolo Wattebled <paolo.wattebled@savoirfairelinux.com>
---
 lib/toaster/tests/views/test_views.py         | 155 +++++++++++++++++-
 lib/toaster/toastergui/buildtables.py         |  11 +-
 lib/toaster/toastergui/templates/recipe.html  |  32 ++++
 .../toastergui/templates/recipe_packages.html |   7 +
 lib/toaster/toastergui/views.py               |  68 +++++++-
 5 files changed, 269 insertions(+), 4 deletions(-)
diff mbox series

Patch

diff --git a/lib/toaster/tests/views/test_views.py b/lib/toaster/tests/views/test_views.py
index e1adfcf86..bed572c76 100644
--- a/lib/toaster/tests/views/test_views.py
+++ b/lib/toaster/tests/views/test_views.py
@@ -10,14 +10,16 @@ 
 """Test cases for Toaster GUI and ReST."""
 
 import os
+import zlib
 import pytest
 from django.test import TestCase
 from django.test.client import RequestFactory
 from django.urls import reverse
 from django.db.models import Q
 
-from orm.models import Project, Package
+from orm.models import Build, Project, Package
 from orm.models import Layer_Version, Recipe
+from orm.models import RecipeVariable
 from orm.models import CustomImageRecipe
 from orm.models import CustomImagePackage
 
@@ -61,6 +63,14 @@  class ViewTests(TestCase):
         if BuildEnvironment.objects.count() == 0:
             BuildEnvironment.objects.create(betype=BuildEnvironment.TYPE_LOCAL)
 
+    @staticmethod
+    def _recipe_variables(build, recipe, values):
+        return RecipeVariable(
+            build=build, recipe=recipe,
+            variable_count=len(values),
+            variables=zlib.compress(json.dumps(
+                values, separators=(',', ':')).encode('utf-8')))
+
 
     def test_get_base_call_returns_html(self):
         """Basic test for all-projects view"""
@@ -90,6 +100,149 @@  class ViewTests(TestCase):
         self.assertTrue(name_found,
                         "project name not found in projects table")
 
+    def test_recipe_variables_tab_scopes_searches_and_escapes(self):
+        build = Build.objects.get(pk=1)
+        other_build = Build.objects.get(pk=2)
+        other_recipe = Recipe.objects.exclude(pk=self.recipe1.pk).first()
+        RecipeVariable.objects.bulk_create([
+            self._recipe_variables(build, self.recipe1, {
+                'SRC_URI': 'git://example.invalid/src',
+                'SPECIAL': '<script>alert(1)</script>',
+            }),
+            self._recipe_variables(other_build, self.recipe1, {
+                'OTHER_BUILD': 'hidden',
+            }),
+            self._recipe_variables(build, other_recipe, {
+                'OTHER_RECIPE': 'hidden',
+            }),
+        ])
+        url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5'))
+
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+            'search': 'SRC_URI',
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, 'recipe.html')
+        self.assertContains(response, 'SRC_URI')
+        self.assertContains(response, 'git://example.invalid/src')
+        self.assertNotContains(response, 'OTHER_BUILD')
+        self.assertNotContains(response, 'OTHER_RECIPE')
+
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+        })
+        self.assertContains(response, 'Variables (2)')
+        self.assertContains(response, '&lt;script&gt;alert(1)&lt;/script&gt;')
+        self.assertNotContains(response, '<script>alert(1)</script>')
+
+    def test_recipe_variables_tab_redirects_paginates_and_handles_empty(self):
+        build = Build.objects.get(pk=1)
+        url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5'))
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 302)
+        self.assertIn('count=100', response.url)
+        self.assertIn('orderby=variable_name%3A%2B', response.url)
+
+        RecipeVariable.objects.bulk_create([
+            self._recipe_variables(build, self.recipe1, {
+                'VAR_%02d' % index: str(index) for index in range(12)
+            })
+        ])
+        response = self.client.get(url, {
+            'count': 10,
+            'page': 2,
+            'orderby': 'variable_name:+',
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context['objects'].paginator.count, 12)
+        self.assertEqual(response.context['objects'].number, 2)
+        self.assertContains(response, 'VAR_10')
+        self.assertContains(response, 'VAR_11')
+        self.assertNotContains(response, 'VAR_00')
+
+        RecipeVariable.objects.filter(build=build, recipe=self.recipe1).delete()
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+        })
+        self.assertContains(response, 'No resolved recipe datastore variables are available.')
+
+    def test_recipe_variables_tab_bounds_query_parameters(self):
+        build = Build.objects.get(pk=1)
+        RecipeVariable.objects.bulk_create([
+            self._recipe_variables(build, self.recipe1, {'SRC_URI': 'value'})
+        ])
+        url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5'))
+
+        response = self.client.get(url, {
+            'count': 'alert(document.domain)',
+            'page': 1,
+            'orderby': 'variable_value:+',
+            'filter': 'variable_value__regex:(a+)+$',
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context['objects'].paginator.per_page, 100)
+        self.assertNotContains(response, 'alert(document.domain)')
+        self.assertEqual(
+            [variable['variable_name']
+             for variable in response.context['objects'].object_list],
+            ['SRC_URI'])
+
+        response = self.client.get(url, {
+            'count': 100,
+            'page': 1,
+            'orderby': 'variable_name:+',
+            'search': ('SRC_URI ' * 20) + ('x' * 300),
+        })
+        self.assertLessEqual(len(response.context['search_term']), 256)
+        self.assertLessEqual(len(response.context['search_term'].split()), 16)
+
+    def test_built_recipes_table_shows_variable_count(self):
+        build = Build.objects.get(pk=1)
+        self._recipe_variables(build, self.recipe1, {
+            'FOO': 'one',
+            'BAR': 'two',
+        }).save()
+        url = reverse('recipes', args=(build.pk,))
+
+        response = self.client.get(url, {
+            'format': 'json',
+            'limit': 25,
+            'page': 1,
+        })
+        data = json.loads(response.content)
+        row = next(row for row in data['rows']
+                   if self.recipe1.name in row['name'])
+
+        self.assertIn('Variables', [column['title'] for column in data['columns']])
+        self.assertIn('>2</a>', row['variable_count'])
+
+    def test_recipe_packages_tab_shows_variable_count(self):
+        build = Build.objects.get(pk=1)
+        self._recipe_variables(build, self.recipe1, {
+            'FOO': 'one',
+            'BAR': 'two',
+        }).save()
+
+        response = self.client.get(reverse(
+            'recipe_packages', args=(build.pk, self.recipe1.pk)), {
+                'count': 10,
+                'page': 1,
+                'orderby': 'name:+',
+            })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, 'Variables (2)')
+
     def test_typeaheads(self):
         """Test typeahead ReST API"""
         layers_url = reverse('xhr_layerstypeahead', args=(self.project.id,))
diff --git a/lib/toaster/toastergui/buildtables.py b/lib/toaster/toastergui/buildtables.py
index 327059d00..52c3dd779 100644
--- a/lib/toaster/toastergui/buildtables.py
+++ b/lib/toaster/toastergui/buildtables.py
@@ -239,7 +239,10 @@  class BuiltRecipesTable(BuildTablesMixin):
     def setup_queryset(self, *args, **kwargs):
         build = Build.objects.get(pk=kwargs['build_id'])
         self.static_context_extra['build'] = build
-        self.queryset = build.get_recipes()
+        self.queryset = build.get_recipes().annotate(
+            variable_count=Sum(
+                'recipevariable__variable_count',
+                filter=Q(recipevariable__build=build), default=0))
         self.queryset = self.queryset.order_by(self.default_orderby)
 
     def setup_columns(self, *args, **kwargs):
@@ -323,6 +326,12 @@  class BuiltRecipesTable(BuildTablesMixin):
                         hideable=False,
                         field_name="version")
 
+        self.add_column(
+            title="Variables",
+            field_name="variable_count",
+            static_data_name="variable_count",
+            static_data_template='<a href="{% url "recipe" extra.build.pk data.pk "5" %}">{{data.variable_count|default:0}}</a>')
+
         self.add_column(title="Dependencies",
                         static_data_name="dependencies",
                         static_data_template=depends_on_tmpl)
diff --git a/lib/toaster/toastergui/templates/recipe.html b/lib/toaster/toastergui/templates/recipe.html
index 4b5301b54..be99ebc54 100644
--- a/lib/toaster/toastergui/templates/recipe.html
+++ b/lib/toaster/toastergui/templates/recipe.html
@@ -52,6 +52,13 @@ 
                 Reverse build dependencies ({{object.r_dependencies_depends.all.count}})
             </a>
         </li>
+        <li class="{{tab_states.5}}">
+            <a href="{% url "recipe" build.pk object.id "5" %}">
+                <span class="glyphicon glyphicon-question-sign get-help" title="Final values
+                    from BitBake's resolved recipe datastore"></span>
+                Variables ({{recipe_variable_count}})
+            </a>
+        </li>
     </ul>
     <div class="tab-content">
         <div class="tab-pane {{tab_states.1}}" id="information">
@@ -277,6 +284,31 @@ 
             {% endif %}
 
         </div>
+        <div class="tab-pane {{tab_states.5}}" id="variables">
+            {% if not objects and not request.GET.search %}
+            <div class="alert alert-info">
+                No resolved recipe datastore variables are available.
+            </div>
+            {% else %}
+                {% with "variables" as search_what %}
+                {% include "detail_search_header.html" %}
+                {% endwith %}
+                {% if objects %}
+                <table class="table table-bordered table-hover tablesorter" id="otable">
+                    {% include "detail_sorted_header.html" %}
+                    <tbody>
+                    {% for variable in objects %}
+                        <tr>
+                            <td>{{variable.variable_name}}</td>
+                            <td>{{variable.variable_value}}</td>
+                        </tr>
+                    {% endfor %}
+                    </tbody>
+                </table>
+                {% include "detail_pagination_bottom.html" %}
+                {% endif %}
+            {% endif %}
+        </div>
     </div>
 </div>
 
diff --git a/lib/toaster/toastergui/templates/recipe_packages.html b/lib/toaster/toastergui/templates/recipe_packages.html
index 37a586f38..abd64ca7e 100644
--- a/lib/toaster/toastergui/templates/recipe_packages.html
+++ b/lib/toaster/toastergui/templates/recipe_packages.html
@@ -51,6 +51,13 @@ 
                 Reverse build dependencies ({{recipe.r_dependencies_depends.all.count}})
             </a>
         </li>
+        <li>
+            <a href="{% url "recipe" build.pk recipe.id "5" %}">
+                <span class="glyphicon glyphicon-question-sign get-help" title="Final values
+                    from BitBake's resolved recipe datastore"></span>
+                Variables ({{variable_count}})
+            </a>
+        </li>
     </ul>
     <div class="tab-content">
 {#        <div class="tab-pane active" id="packages-built" name="packages-built">#}
diff --git a/lib/toaster/toastergui/views.py b/lib/toaster/toastergui/views.py
index 061e6436c..26cf5ca26 100644
--- a/lib/toaster/toastergui/views.py
+++ b/lib/toaster/toastergui/views.py
@@ -10,6 +10,7 @@  import ast
 import re
 import subprocess
 import sys
+import zlib
 
 import bb.cooker
 from bb.ui import toasterui
@@ -20,6 +21,7 @@  from django.db import IntegrityError
 from django.shortcuts import render, redirect, get_object_or_404, HttpResponseRedirect
 from django.utils.http import urlencode
 from orm.models import Build, Target, Task, Layer, Layer_Version, Recipe
+from orm.models import RecipeVariable
 from orm.models import LogMessage, Variable, Package_Dependency, Package
 from orm.models import Task_Dependency, Package_File
 from orm.models import Target_Installed_Package, Target_File
@@ -619,10 +621,13 @@  def recipe(request, build_id, recipe_id, active_tab="1"):
     layer  = Layer.objects.get(pk=layer_version.layer_id)
     tasks_list  = Task.objects.filter(recipe_id = recipe_id, build_id = build_id).exclude(order__isnull=True).exclude(task_name__endswith='_setscene').exclude(outcome=Task.OUTCOME_NA)
     package_count = Package.objects.filter(recipe_id = recipe_id).filter(build_id = build_id).filter(size__gte=0).count()
+    recipe_variable_count = RecipeVariable.objects.filter(
+        build_id=build_id, recipe_id=recipe_id).values_list(
+            'variable_count', flat=True).first() or 0
 
-    if active_tab != '1' and active_tab != '3' and active_tab != '4' :
+    if active_tab not in ('1', '3', '4', '5'):
         active_tab = '1'
-    tab_states = {'1': '', '3': '', '4': ''}
+    tab_states = {'1': '', '3': '', '4': '', '5': ''}
     tab_states[active_tab] = 'active'
 
     context = {
@@ -632,8 +637,63 @@  def recipe(request, build_id, recipe_id, active_tab="1"):
             'layer'   : layer,
             'tasks'   : tasks_list,
             'package_count' : package_count,
+            'recipe_variable_count' : recipe_variable_count,
             'tab_states' : tab_states,
     }
+
+    if active_tab == '5':
+        (requested_pagesize, requested_orderby) = _get_parameters_values(
+            request, 100, 'variable_name:+')
+        pagesize = requested_pagesize \
+            if str(requested_pagesize) in ('10', '25', '50', '100', '150') \
+            else 100
+        orderby = requested_orderby \
+            if requested_orderby in ('variable_name:+', 'variable_name:-') \
+            else 'variable_name:+'
+        mandatory_parameters = {
+            'count': pagesize,
+            'page': 1,
+            'orderby': orderby,
+        }
+        if _verify_parameters(request.GET, mandatory_parameters):
+            return _redirect_parameters(
+                'recipe', request.GET, mandatory_parameters,
+                build_id=build_id, recipe_id=recipe_id, active_tab='5')
+
+        search_term = request.GET.get('search', '')[:256]
+        search_term = ' '.join(search_term.split()[:16])
+        snapshot = RecipeVariable.objects.filter(
+            build_id=build_id, recipe_id=recipe_id).first()
+        variables = []
+        if snapshot:
+            values = json.loads(zlib.decompress(snapshot.variables).decode('utf-8'))
+            variables = [
+                {'variable_name': name, 'variable_value': value}
+                for name, value in values.items()
+                if not search_term or search_term.lower() in name.lower()
+                or search_term.lower() in value.lower()
+            ]
+        variables.sort(
+            key=lambda variable: variable['variable_name'],
+            reverse=orderby.endswith(':-'))
+        context['variable_count'] = len(variables)
+        context['objects'] = _build_page_range(
+            Paginator(variables, pagesize), request.GET.get('page', 1))
+        context['object_count'] = context['variable_count']
+        context['validated_pagesize'] = pagesize
+        context['search_term'] = search_term
+        context['search_orderby'] = 'variable_name:+'
+        context['tablecols'] = [
+            {
+                'name': 'Variable',
+                'orderfield': _get_toggle_order(request, 'variable_name'),
+                'ordericon': _get_toggle_order_icon(request, 'variable_name'),
+                'orderkey': 'variable_name',
+            },
+            {'name': 'Value'},
+        ]
+        _set_parameters_values(pagesize, orderby, request)
+
     return toaster_render(request, template, context)
 
 def recipe_packages(request, build_id, recipe_id):
@@ -651,6 +711,9 @@  def recipe_packages(request, build_id, recipe_id):
     recipe_object = Recipe.objects.get(pk=recipe_id)
     queryset = Package.objects.filter(recipe_id = recipe_id).filter(build_id = build_id).filter(size__gte=0)
     package_count = queryset.count()
+    variable_count = RecipeVariable.objects.filter(
+        build_id=build_id, recipe_id=recipe_id).values_list(
+            'variable_count', flat=True).first() or 0
     queryset = _get_queryset(Package, queryset, filter_string, search_term, ordering_string, 'name')
 
     packages = _build_page_range(Paginator(queryset, pagesize),request.GET.get('page', 1))
@@ -660,6 +723,7 @@  def recipe_packages(request, build_id, recipe_id):
             'recipe'  : recipe_object,
             'objects'  : packages,
             'object_count' : package_count,
+            'variable_count' : variable_count,
             'tablecols':[
                 {
                     'name':'Package',