please pull toaster patchset
cover please pull toaster patchset
From: Alexandru DAMIAN <[email protected]>
Hi,
This is a toaster patchset consisting of new features (New Project page), and bug fixes in toasterui data gathering, better layer identification, ability to use http_proxy to fetch data, and other fixes.
This patch has been reviewed on "toaster/master" poky contrib.
Can you please pull at your convenience ?
Cheers,
Alex
The following changes since commit 27023ce2d264ce05008ef9af33982b054c6a87b5:
toaster: do not show target if target name is empty (2014-11-20 15:43:57 +0000)
are available in the git repository at:
git://git.yoctoproject.org/poky-contrib adamian/20141127-submission-bb
http://git.yoctoproject.org/cgit.cgi/poky-contrib/log/?h=adamian/20141127-submission-bb
Alexandru DAMIAN (8):
toaster: use http proxies to fetch data
toastergui: update layer search criteria
toastergui: do not show project info in interactive mode
toaster: display Toaster exceptions and other fixes
toasterui: fix layer identification for managed builds
toaster: fix loadconf path calculation
toastergui: new project page
toasterui: Compatibility patch for daisy and dizzy
Belen Barros Pena (5):
toaster: fix errors and warnings display
toaster: make 'latest builds' section consistent across pages
toaster: fix padding of build notifications
toaster: release name consistency
toaster: remove the word 'project' from layers and machine
Michael Wood (1):
toaster: base Only show New Build button when there are > 0 projects
lib/bb/ui/buildinfohelper.py | 96 ++++-- lib/bb/ui/toasterui.py | 8 +- .../management/commands/checksettings.py | 8 + .../bldcontrol/management/commands/loadconf.py | 42 +-- lib/toaster/bldcontrol/tests.py | 19 ++ .../0018_auto__add_field_layer_version_project.py | 331 +++++++++++++++++++++ lib/toaster/orm/models.py | 58 +++- lib/toaster/toastergui/static/css/default.css | 4 + lib/toaster/toastergui/static/js/base.js | 13 +- lib/toaster/toastergui/static/js/libtoaster.js | 7 + lib/toaster/toastergui/templates/base.html | 5 +- .../toastergui/templates/builddashboard.html | 35 +++ lib/toaster/toastergui/templates/layers.html | 9 +- lib/toaster/toastergui/templates/mrb_section.html | 25 +- lib/toaster/toastergui/templates/newproject.html | 116 ++++++-- lib/toaster/toastergui/templates/project.html | 28 +- lib/toaster/toastergui/templatetags/projecttags.py | 8 + lib/toaster/toastergui/views.py | 70 +++-- 18 files changed, 736 insertions(+), 146 deletions(-) create mode 100644 lib/toaster/orm/migrations/0018_auto__add_field_layer_version_project.py
Signature · 1 line
1.9.1
PATCH 1/14 toaster: use http proxies to fetch data
From: Alexandru DAMIAN <[email protected]>
Under some network configurations http proxies are used for Internet access. This patch makes Toaster obey the http_proxy environment variable when fetching information from layer indexes.
--- lib/toaster/orm/models.py | 45 ++++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-)
lib/toaster/orm/models.py +32 -13
@@ -599,24 +599,41 @@ class LayerIndexLayerSource(LayerSource): assert self.apiurl is not None from django.db import IntegrityError + import httplib, urlparse, json + import os + proxy_settings = os.environ.get("http_proxy", None) + def _get_json_response(apiurl = self.apiurl): - import httplib, urlparse, json - parsedurl = urlparse.urlparse(apiurl) - try: - (host, port) = parsedurl.netloc.split(":") - except ValueError: - host = parsedurl.netloc - port = None + conn = None + _parsedurl = urlparse.urlparse(apiurl) + path = _parsedurl.path + query = _parsedurl.query + def parse_url(url): + parsedurl = urlparse.urlparse(url) + try: + (host, port) = parsedurl.netloc.split(":") + except ValueError: + host = parsedurl.netloc + port = None + + if port is None: + port = 80 + else: + port = int(port) + return (host, port) - if port is None: - port = 80 + if proxy_settings is None: + host, port = parse_url(apiurl) + conn = httplib.HTTPConnection(host, port) + conn.request("GET", path + "?" + query) else: - port = int(port) - conn = httplib.HTTPConnection(host, port) - conn.request("GET", parsedurl.path + "?" + parsedurl.query) + host, port = parse_url(proxy_settings) + conn = httplib.HTTPConnection(host, port) + conn.request("GET", apiurl) + r = conn.getresponse() if r.status != 200: - raise Exception("Failed to read " + parsedurl.path + ": %d %s" % (r.status, r.reason)) + raise Exception("Failed to read " + path + ": %d %s" % (r.status, r.reason)) return json.loads(r.read()) # verify we can get the basic api
@@ -624,6 +641,8 @@ class LayerIndexLayerSource(LayerSource): apilinks = _get_json_response() except Exception as e: import traceback + if proxy_settings is not None: + print "EE: Using proxy ", proxy_settings print "EE: could not connect to %s, skipping update: %s\n%s" % (self.apiurl, e, traceback.format_exc(e)) return
Signature · 1 line
1.9.1
PATCH 2/14 toaster: base Only show New Build button when there are > 0 projects
From: Michael Wood <[email protected]>
Only show new build button if we have defined at least one project as we can't select a project to build against if there are no projects created yet.
--- lib/toaster/toastergui/static/js/base.js | 13 +++++++++---- lib/toaster/toastergui/templates/base.html | 1 + 2 files changed, 10 insertions(+), 4 deletions(-)
lib/toaster/toastergui/static/js/base.js +9 -4
@@ -3,10 +3,12 @@ function basePageInit (ctx) { var newBuildButton = $("#new-build-button"); - /* Hide the button if we're on the project,newproject or importlyaer page */ - if (ctx.currentUrl.search('newproject|project/\\d/$|importlayer/$') > 0){ - newBuildButton.hide(); - return; + /* Hide the button if we're on the project,newproject or importlyaer page + * or if there are no projects yet defined + */ + if (ctx.numProjects == 0 || ctx.currentUrl.search('newproject|project/\\d/$|importlayer/$') > 0){ + newBuildButton.hide(); + return; }
@@ -17,6 +19,9 @@ function basePageInit (ctx) { function _checkProjectBuildable(){ + if (ctx.projectId == undefined) + return; + libtoaster.getProjectInfo(ctx.projectInfoUrl, ctx.projectId, function(data){ if (data.machine.name == undefined || data.layers.length == 0) {
lib/toaster/toastergui/templates/base.html +1 -0
@@ -31,6 +31,7 @@ ctx.projectBuildUrl = "{% url 'xhr_build' %}"; ctx.projectPageUrl = "{% url 'project' %}"; ctx.projectInfoUrl = "{% url 'xhr_projectinfo' %}"; + ctx.numProjects = {{projects|length}}; {% if project %} ctx.projectId = {{project.id}}; {% endif %}
Signature · 1 line
1.9.1
PATCH 3/14 toastergui: update layer search criteria
In reply to Alex DAMIANFrom: Alexandru DAMIAN <[email protected]>
In order to accomodate the designs around imported layer, we add a "project" field for in the layer versions.
The field must be set if and only if the layer is manually imported in the project. This will prevent information leakage between different projects.
The views have been updated to perform relevant layer queries in a single location.
--- .../0018_auto__add_field_layer_version_project.py | 331 +++++++++++++++++++++ lib/toaster/orm/models.py | 4 +- lib/toaster/toastergui/views.py | 59 ++-- 3 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 lib/toaster/orm/migrations/0018_auto__add_field_layer_version_project.py
lib/toaster/orm/migrations/0018_auto__add_field_layer_version_project.py new file +331 -0
@@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'Layer_Version.project' + db.add_column(u'orm_layer_version', 'project', + self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['orm.Project'], null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Layer_Version.project' + db.delete_column(u'orm_layer_version', 'project_id') + + + models = { + u'orm.bitbakeversion': { + 'Meta': {'object_name': 'BitbakeVersion'}, + 'branch': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'dirpath': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'giturl': ('django.db.models.fields.URLField', [], {'max_length': '200'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}) + }, + u'orm.branch': { + 'Meta': {'unique_together': "(('layer_source', 'name'), ('layer_source', 'up_id'))", 'object_name': 'Branch'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer_source': ('django.db.models.fields.related.ForeignKey', [], {'default': 'True', 'to': u"orm['orm.LayerSource']", 'null': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'short_description': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'up_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'up_id': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True'}) + }, + u'orm.build': { + 'Meta': {'object_name': 'Build'}, + 'bitbake_version': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'build_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'completed_on': ('django.db.models.fields.DateTimeField', [], {}), + 'cooker_log_path': ('django.db.models.fields.CharField', [], {'max_length': '500'}), + 'distro': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'distro_version': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'errors_no': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'machine': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'outcome': ('django.db.models.fields.IntegerField', [], {'default': '2'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Project']", 'null': 'True'}), + 'started_on': ('django.db.models.fields.DateTimeField', [], {}), + 'timespent': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'warnings_no': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + u'orm.helptext': { + 'Meta': {'object_name': 'HelpText'}, + 'area': ('django.db.models.fields.IntegerField', [], {}), + 'build': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'helptext_build'", 'to': u"orm['orm.Build']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'text': ('django.db.models.fields.TextField', [], {}) + }, + u'orm.layer': { + 'Meta': {'unique_together': "(('layer_source', 'up_id'), ('layer_source', 'name'))", 'object_name': 'Layer'}, + 'description': ('django.db.models.fields.TextField', [], {'default': 'None', 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer_index_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}), + 'layer_source': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['orm.LayerSource']", 'null': 'True'}), + 'local_path': ('django.db.models.fields.FilePathField', [], {'default': 'None', 'max_length': '255', 'null': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'summary': ('django.db.models.fields.TextField', [], {'default': 'None', 'null': 'True'}), + 'up_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'up_id': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True'}), + 'vcs_url': ('django.db.models.fields.URLField', [], {'default': 'None', 'max_length': '200', 'null': 'True'}), + 'vcs_web_file_base_url': ('django.db.models.fields.URLField', [], {'default': 'None', 'max_length': '200', 'null': 'True'}), + 'vcs_web_tree_base_url': ('django.db.models.fields.URLField', [], {'default': 'None', 'max_length': '200', 'null': 'True'}), + 'vcs_web_url': ('django.db.models.fields.URLField', [], {'default': 'None', 'max_length': '200', 'null': 'True'}) + }, + u'orm.layer_version': { + 'Meta': {'unique_together': "(('layer_source', 'up_id'),)", 'object_name': 'Layer_Version'}, + 'branch': ('django.db.models.fields.CharField', [], {'max_length': '80'}), + 'build': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'layer_version_build'", 'null': 'True', 'to': u"orm['orm.Build']"}), + 'commit': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'dirpath': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'layer_version_layer'", 'to': u"orm['orm.Layer']"}), + 'layer_source': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['orm.LayerSource']", 'null': 'True'}), + 'priority': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['orm.Project']", 'null': 'True'}), + 'up_branch': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['orm.Branch']", 'null': 'True'}), + 'up_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'up_id': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True'}) + }, + u'orm.layersource': { + 'Meta': {'unique_together': "(('sourcetype', 'apiurl'),)", 'object_name': 'LayerSource'}, + 'apiurl': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '63'}), + 'sourcetype': ('django.db.models.fields.IntegerField', [], {}) + }, + u'orm.layerversiondependency': { + 'Meta': {'unique_together': "(('layer_source', 'up_id'),)", 'object_name': 'LayerVersionDependency'}, + 'depends_on': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'dependees'", 'to': u"orm['orm.Layer_Version']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer_source': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['orm.LayerSource']", 'null': 'True'}), + 'layer_version': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'dependencies'", 'to': u"orm['orm.Layer_Version']"}), + 'up_id': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True'}) + }, + u'orm.logmessage': { + 'Meta': {'object_name': 'LogMessage'}, + 'build': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Build']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'level': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'lineno': ('django.db.models.fields.IntegerField', [], {'null': 'True'}), + 'message': ('django.db.models.fields.CharField', [], {'max_length': '240'}), + 'pathname': ('django.db.models.fields.FilePathField', [], {'max_length': '255', 'blank': 'True'}), + 'task': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Task']", 'null': 'True', 'blank': 'True'}) + }, + u'orm.machine': { + 'Meta': {'unique_together': "(('layer_source', 'up_id'),)", 'object_name': 'Machine'}, + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer_source': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['orm.LayerSource']", 'null': 'True'}), + 'layer_version': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Layer_Version']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'up_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'up_id': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True'}) + }, + u'orm.package': { + 'Meta': {'object_name': 'Package'}, + 'build': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Build']"}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'installed_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100'}), + 'installed_size': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'license': ('django.db.models.fields.CharField', [], {'max_length': '80', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'recipe': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Recipe']", 'null': 'True'}), + 'revision': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'section': ('django.db.models.fields.CharField', [], {'max_length': '80', 'blank': 'True'}), + 'size': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'summary': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'version': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}) + }, + u'orm.package_dependency': { + 'Meta': {'object_name': 'Package_Dependency'}, + 'dep_type': ('django.db.models.fields.IntegerField', [], {}), + 'depends_on': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'package_dependencies_target'", 'to': u"orm['orm.Package']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'package': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'package_dependencies_source'", 'to': u"orm['orm.Package']"}), + 'target': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Target']", 'null': 'True'}) + }, + u'orm.package_file': { + 'Meta': {'object_name': 'Package_File'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'package': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'buildfilelist_package'", 'to': u"orm['orm.Package']"}), + 'path': ('django.db.models.fields.FilePathField', [], {'max_length': '255', 'blank': 'True'}), + 'size': ('django.db.models.fields.IntegerField', [], {}) + }, + u'orm.project': { + 'Meta': {'object_name': 'Project'}, + 'bitbake_version': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.BitbakeVersion']"}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'release': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Release']"}), + 'short_description': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user_id': ('django.db.models.fields.IntegerField', [], {'null': 'True'}) + }, + u'orm.projectlayer': { + 'Meta': {'unique_together': "(('project', 'layercommit'),)", 'object_name': 'ProjectLayer'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layercommit': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Layer_Version']", 'null': 'True'}), + 'optional': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Project']"}) + }, + u'orm.projecttarget': { + 'Meta': {'object_name': 'ProjectTarget'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Project']"}), + 'target': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'task': ('django.db.models.fields.CharField', [], {'max_length': '100', 'null': 'True'}) + }, + u'orm.projectvariable': { + 'Meta': {'object_name': 'ProjectVariable'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Project']"}), + 'value': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + u'orm.recipe': { + 'Meta': {'unique_together': "(('layer_version', 'file_path'),)", 'object_name': 'Recipe'}, + 'bugtracker': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'file_path': ('django.db.models.fields.FilePathField', [], {'max_length': '255'}), + 'homepage': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer_source': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['orm.LayerSource']", 'null': 'True'}), + 'layer_version': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'recipe_layer_version'", 'to': u"orm['orm.Layer_Version']"}), + 'license': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'section': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'summary': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'up_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'up_id': ('django.db.models.fields.IntegerField', [], {'default': 'None', 'null': 'True'}), + 'version': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}) + }, + u'orm.recipe_dependency': { + 'Meta': {'object_name': 'Recipe_Dependency'}, + 'dep_type': ('django.db.models.fields.IntegerField', [], {}), + 'depends_on': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'r_dependencies_depends'", 'to': u"orm['orm.Recipe']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recipe': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'r_dependencies_recipe'", 'to': u"orm['orm.Recipe']"}) + }, + u'orm.release': { + 'Meta': {'object_name': 'Release'}, + 'bitbake_version': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.BitbakeVersion']"}), + 'branch_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '50'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'helptext': ('django.db.models.fields.TextField', [], {'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}) + }, + u'orm.releasedefaultlayer': { + 'Meta': {'object_name': 'ReleaseDefaultLayer'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100'}), + 'release': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Release']"}) + }, + u'orm.releaselayersourcepriority': { + 'Meta': {'unique_together': "(('release', 'layer_source'),)", 'object_name': 'ReleaseLayerSourcePriority'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'layer_source': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.LayerSource']"}), + 'priority': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'release': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Release']"}) + }, + u'orm.target': { + 'Meta': {'object_name': 'Target'}, + 'build': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Build']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image_size': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'is_image': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'license_manifest_path': ('django.db.models.fields.CharField', [], {'max_length': '500', 'null': 'True'}), + 'target': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'orm.target_file': { + 'Meta': {'object_name': 'Target_File'}, + 'directory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'directory_set'", 'null': 'True', 'to': u"orm['orm.Target_File']"}), + 'group': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inodetype': ('django.db.models.fields.IntegerField', [], {}), + 'owner': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'path': ('django.db.models.fields.FilePathField', [], {'max_length': '100'}), + 'permission': ('django.db.models.fields.CharField', [], {'max_length': '16'}), + 'size': ('django.db.models.fields.IntegerField', [], {}), + 'sym_target': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'symlink_set'", 'null': 'True', 'to': u"orm['orm.Target_File']"}), + 'target': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Target']"}) + }, + u'orm.target_image_file': { + 'Meta': {'object_name': 'Target_Image_File'}, + 'file_name': ('django.db.models.fields.FilePathField', [], {'max_length': '254'}), + 'file_size': ('django.db.models.fields.IntegerField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'target': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Target']"}) + }, + u'orm.target_installed_package': { + 'Meta': {'object_name': 'Target_Installed_Package'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'package': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'buildtargetlist_package'", 'to': u"orm['orm.Package']"}), + 'target': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['orm.Target']"}) + }, + u'orm.task': { + 'Meta': {'ordering': "('order', 'recipe')", 'unique_together': "(('build', 'recipe', 'task_name'),)", 'object_name': 'Task'}, + 'build': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'task_build'", 'to': u"orm['orm.Build']"}), + 'cpu_usage': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '6', 'decimal_places': '2'}), + 'disk_io': ('django.db.models.fields.IntegerField', [], {'null': 'True'}), + 'elapsed_time': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '6', 'decimal_places': '2'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_number': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'logfile': ('django.db.models.fields.FilePathField', [], {'max_length': '255', 'blank': 'True'}), + 'message': ('django.db.models.fields.CharField', [], {'max_length': '240'}), + 'order': ('django.db.models.fields.IntegerField', [], {'null': 'True'}), + 'outcome': ('django.db.models.fields.IntegerField', [], {'default': '-1'}), + 'path_to_sstate_obj': ('django.db.models.fields.FilePathField', [], {'max_length': '500', 'blank': 'True'}), + 'recipe': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'build_recipe'", 'to': u"orm['orm.Recipe']"}), + 'script_type': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'source_url': ('django.db.models.fields.FilePathField', [], {'max_length': '255', 'blank': 'True'}), + 'sstate_checksum': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'sstate_result': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'task_executed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'task_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'work_directory': ('django.db.models.fields.FilePathField', [], {'max_length': '255', 'blank': 'True'}) + }, + u'orm.task_dependency': { + 'Meta': {'object_name': 'Task_Dependency'}, + 'depends_on': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'task_dependencies_depends'", 'to': u"orm['orm.Task']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'task': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'task_dependencies_task'", 'to': u"orm['orm.Task']"}) + }, + u'orm.toastersetting': { + 'Meta': {'object_name': 'ToasterSetting'}, + 'helptext': ('django.db.models.fields.TextField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '63'}), + 'value': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + u'orm.variable': { + 'Meta': {'object_name': 'Variable'}, + 'build': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'variable_build'", 'to': u"orm['orm.Build']"}), + 'changed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'human_readable_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'variable_name': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'variable_value': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + }, + u'orm.variablehistory': { + 'Meta': {'object_name': 'VariableHistory'}, + 'file_name': ('django.db.models.fields.FilePathField', [], {'max_length': '255'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_number': ('django.db.models.fields.IntegerField', [], {'null': 'True'}), + 'operation': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'value': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'variable': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'vhistory'", 'to': u"orm['orm.Variable']"}) + } + } + + complete_apps = ['orm']
lib/toaster/orm/models.py +3 -1
@@ -843,6 +843,8 @@ class Layer_Version(models.Model): dirpath = models.CharField(max_length=255, null = True, default = None) # LayerBranch.vcs_subdir priority = models.IntegerField(default = 0) # if -1, this is a default layer + project = models.ForeignKey('Project', null = True, default = None) # Set if this layer is project-specific; always set for imported layers, and project-set branches + # code lifted, with adaptations, from the layerindex-web application https://git.yoctoproject.org/cgit/cgit.cgi/layerindex-web/ def _handle_url_path(self, base_url, path): import re
@@ -902,7 +904,7 @@ class Layer_Version(models.Model): return sorted( Layer_Version.objects.filter( layer__name = self.layer.name, up_branch__name = self.up_branch.name ), key = lambda x: _get_ls_priority(x.layer_source), - reverse = False) + reverse = True) def __unicode__(self):
lib/toaster/toastergui/views.py +37 -22
111 lines not on this page · raw message
Signature · 1 line
1.9.1
PATCH 4/14 toastergui: do not show project info in interactive mode
In reply to Alex DAMIANFrom: Alexandru DAMIAN <[email protected]>
The most recent builds section showed the project name and the Run Again buttons regardless whenever the Toaster was in interactive or managed modes. These widgets have no meaning in interactive mode, and this patch disables the widgets if toaster is not running in Managed mode.
[YOCTO #6776]
--- lib/toaster/toastergui/templates/mrb_section.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-)
lib/toaster/toastergui/templates/mrb_section.html +3 -3
@@ -13,7 +13,7 @@ <div id="latest-builds"> {% for build in mru %} <div class="alert {%if build.outcome == build.SUCCEEDED%}alert-success{%elif build.outcome == build.FAILED%}alert-error{%else%}alert-info{%endif%}" style="padding-top: 0;"> - {% if build.project %} + {% if MANAGED and build.project %} <span class="label {%if build.outcome == build.SUCCEEDED%}label-success{%elif build.outcome == build.FAILED%}label-danger{%else%}label-info{%endif%}" style="font-weight: normal; margin-bottom: 5px; margin-left:-15px; padding-top:5px;"> {{build.project.name}} </span> {% endif %}
@@ -40,10 +40,10 @@ {% endif %} </div> <div class="lead "> - <span class="lead{%if not build.project%} pull-right{%endif%}"> + <span class="lead{%if not MANAGED or not build.project%} pull-right{%endif%}"> Build time: <a href="{% url 'buildtime' build.pk %}">{{ build.timespent|sectohms }}</a> </span> - {% if build.project %} + {% if MANAGED and build.project %} <a class="btn {%if build.outcome == build.SUCCEEDED%}btn-success{%elif build.outcome == build.FAILED%}btn-danger{%else%}btn-info{%endif%} pull-right" onclick="scheduleBuild({% url 'xhr_projectbuild' build.project.id as bpi%}{{bpi|json}}, {{build.project.name|json}}, {{build.get_sorted_target_list|mapselect:'target'|json}})">Run again</a> {% endif %} </div>
Signature · 1 line
1.9.1
PATCH 5/14 toaster: display Toaster exceptions and other fixes
In reply to Alex DAMIANFrom: Alexandru DAMIAN <[email protected]>
Changing ToasterUI to log toaster exceptions on a different level than build errors.
Updating the build dashboard to show Toaster exceptions.
We add extra logging to console for exceptions.
Fixed a problem where packages database entries were created instead of being looked up in the database, conficting with entries created to satisfy dependency information.
Toaster now checks for invalid states at startup and performs needed cleanups.
Removed loading reference to jquery-ui.min.css as we do not have this file.
--- lib/bb/ui/buildinfohelper.py | 15 ++++++++-- lib/bb/ui/toasterui.py | 7 +++-- .../management/commands/checksettings.py | 8 +++++ lib/toaster/orm/models.py | 9 +++++- lib/toaster/toastergui/static/js/libtoaster.js | 7 +++++ lib/toaster/toastergui/templates/base.html | 2 +- .../toastergui/templates/builddashboard.html | 35 ++++++++++++++++++++++ lib/toaster/toastergui/views.py | 2 +- 8 files changed, 77 insertions(+), 8 deletions(-)
lib/bb/ui/buildinfohelper.py +13 -2
@@ -381,7 +381,7 @@ class ORMWrapper(object): searchname = pkgpnmap[p]['OPKGN'] packagedict[p]['object'], created = Package.objects.get_or_create( build = build_obj, name = searchname ) - if created or package[p]['object'].size == -1: # save the data anyway we can, not just if it was not created here; bug [YOCTO #6887] + if created or packagedict[p]['object'].size == -1: # save the data anyway we can, not just if it was not created here; bug [YOCTO #6887] # fill in everything we can from the runtime-reverse package data try: packagedict[p]['object'].recipe = recipes[pkgpnmap[p]['PN']]
@@ -462,7 +462,7 @@ class ORMWrapper(object): if 'OPKGN' in package_info.keys(): pname = package_info['OPKGN'] - bp_object = Package.objects.create( build = build_obj, + bp_object, created = Package.objects.get_or_create( build = build_obj, name = pname ) bp_object.installed_name = package_info['PKG']
@@ -1043,6 +1043,15 @@ class BuildInfoHelper(object): mockevent.lineno = -1 self.store_log_event(mockevent) + def store_log_exception(self, text, backtrace = ""): + mockevent = MockEvent() + mockevent.levelno = -1 + mockevent.msg = text + mockevent.pathname = backtrace + mockevent.lineno = -1 + self.store_log_event(mockevent) + + def store_log_event(self, event): if event.levelno < format.WARNING: return
@@ -1078,6 +1087,8 @@ class BuildInfoHelper(object): log_information['level'] = LogMessage.ERROR elif event.levelno == format.WARNING: log_information['level'] = LogMessage.WARNING + elif event.levelno == -1: # toaster self-logging + log_information['level'] = -1 else: log_information['level'] = LogMessage.INFO
lib/bb/ui/toasterui.py +4 -3
@@ -299,12 +299,13 @@ def main(server, eventHandler, params ): logger.error(e) import traceback exception_data = traceback.format_exc() + print(exception_data) # save them to database, if possible; if it fails, we already logged to console. try: - buildinfohelper.store_log_error("%s\n%s" % (str(e), exception_data)) - except Exception: - pass + buildinfohelper.store_log_exception("%s\n%s" % (str(e), exception_data)) + except Exception as ce: + print("CRITICAL: failed to to save toaster exception to the database: %s" % str(ce)) pass
lib/toaster/bldcontrol/management/commands/checksettings.py +8 -0
@@ -139,4 +139,12 @@ class Command(NoArgsCommand): ToasterSetting.objects.filter(name = 'DEFAULT_RELEASE').delete() ToasterSetting.objects.get_or_create(name = 'DEFAULT_RELEASE', value = '') + # we are just starting up. we must not have any builds in progress, or build environments taken + for b in BuildRequest.objects.filter(state = BuildRequest.REQ_INPROGRESS): + BRError.objects.create(req = b, errtype = "toaster", errmsg = "Toaster found this build IN PROGRESS while Toaster started up. This is an inconsistent state, and the build was marked as failed") + + BuildRequest.objects.filter(state = BuildRequest.REQ_INPROGRESS).update(state = BuildRequest.REQ_FAILED) + + BuildEnvironment.objects.update(lock = BuildEnvironment.LOCK_FREE) + return 0
lib/toaster/orm/models.py +8 -1
@@ -178,6 +178,11 @@ class Build(models.Model): tgts = Target.objects.filter(build_id = self.id).order_by( 'target' ); return( tgts ); + @property + def toaster_exceptions(self): + return self.logmessage_set.filter(level=LogMessage.EXCEPTION) + + class ProjectTarget(models.Model): project = models.ForeignKey(Project) target = models.CharField(max_length=100)
@@ -966,13 +971,15 @@ class HelpText(models.Model): text = models.TextField() class LogMessage(models.Model): + EXCEPTION = -1 # used to signal self-toaster-exceptions INFO = 0 WARNING = 1 ERROR = 2 LOG_LEVEL = ( (INFO, "info"), (WARNING, "warn"), - (ERROR, "error") ) + (ERROR, "error"), + (EXCEPTION, "toaster exception")) build = models.ForeignKey(Build) task = models.ForeignKey(Task, blank = True, null=True)
lib/toaster/toastergui/static/js/libtoaster.js +7 -0
@@ -252,6 +252,13 @@ $(document).ready(function() { $('.toggle-warnings').click(function() { $('#collapse-warnings').toggleClass('in'); }); + $('.show-exceptions').click(function() { + $('#collapse-exceptions').addClass('in'); + }); + $('.toggle-exceptions').click(function() { + $('#collapse-exceptions').toggleClass('in'); + }); + //show warnings section when requested from the previous page if (location.href.search('#warnings') > -1) { $('#collapse-warnings').addClass('in');
lib/toaster/toastergui/templates/base.html +1 -1
@@ -8,7 +8,7 @@ <link rel="stylesheet" href="{% static 'css/font-awesome.min.css' %}" type='text/css'> <link rel="stylesheet" href="{% static 'css/prettify.css' %}" type='text/css'> <link rel="stylesheet" href="{% static 'css/default.css' %}" type='text/css'> -<link rel="stylesheet" href="assets/css/jquery-ui-1.10.3.custom.min.css" type='text/css'> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="Content-Type" content="text/html;charset=UTF-8"> <script src="{% static 'js/jquery-2.0.3.min.js' %}">
lib/toaster/toastergui/templates/builddashboard.html +35 -0
@@ -39,6 +39,14 @@ <span class="pull-right">Build time: <a href="{% url 'buildtime' build.pk %}">{{ build.timespent|sectohms }}</a></span> {%endif%} </div> + {% if build.toaster_exceptions.count > 0 %} + <div class="row"> + <small class="pull-right"> + <i class="icon-question-sign get-help get-help-blue" title="" data-original-title="Toaster exceptions do not affect your build: only the operation of Toaster"></i> + <a class="show-exceptions" href="#exceptions">Toaster threw {{build.toaster_exceptions.count}} exception{{build.toaster_exceptions.count|pluralize}}</a> + </small> + </div> + {% endif %} </div> </div>
@@ -223,6 +231,33 @@ </div> {% endif %} + +{% if build.toaster_exceptions.count > 0 %} +<div class="accordion span10 pull-right" id="exceptions"> + <div class="accordion-group"> + <div class="accordion-heading"> + <a class="accordion-toggle exception toggle-exceptions"> + <h2 id="exception-toggle"> + <i class="icon-warning-sign"></i> + {{build.toaster_exceptions.count}} Toaster exception{{build.toaster_exceptions.count|pluralize}} + </h2> + </a> + </div> + <div class="accordion-body collapse" id="collapse-exceptions"> + <div class="accordion-inner"> + <div class="span10"> + {% for exception in build.toaster_exceptions %} + <div class="alert alert-exception"> + <pre>{{exception.message}}</pre> + </div> + {% endfor %} + </div> + </div> + </div> + </div> +</div> +{% endif %} + <script type="text/javascript"> $(document).ready(function() { //show warnings section when requested from the previous page
lib/toaster/toastergui/views.py +1 -1
@@ -461,7 +461,7 @@ def builddashboard( request, build_id ): template = "builddashboard.html" if Build.objects.filter( pk=build_id ).count( ) == 0 : return redirect( builds ) - build = Build.objects.filter( pk = build_id )[ 0 ]; + build = Build.objects.get( pk = build_id ); layerVersionId = Layer_Version.objects.filter( build = build_id ); recipeCount = Recipe.objects.filter( layer_version__id__in = layerVersionId ).count( ); tgts = Target.objects.filter( build_id = build_id ).order_by( 'target' );
Signature · 1 line
1.9.1
PATCH 6/14 toasterui: fix layer identification for managed builds
In reply to Alex DAMIANFrom: Alexandru DAMIAN <[email protected]>
If we have a managed build, we match the layers used for build with the layers configured for project, as we know where the layers are coming from
[YOCTO #6962]
--- lib/bb/ui/buildinfohelper.py | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-)
lib/bb/ui/buildinfohelper.py +25 -13
@@ -117,15 +117,14 @@ class ORMWrapper(object): if brbe is not None: from bldcontrol.models import BuildEnvironment, BuildRequest - try: - br, be = brbe.split(":") - buildrequest = BuildRequest.objects.get(pk = br) - buildrequest.build = build - buildrequest.save() - build.project_id = buildrequest.project_id - build.save() - except BuildRequest.DoesNotExist: - pass + br, be = brbe.split(":") + + buildrequest = BuildRequest.objects.get(pk = br) + buildrequest.build = build + buildrequest.save() + + build.project_id = buildrequest.project_id + build.save() return build def create_target_objects(self, target_info):
@@ -250,17 +249,30 @@ class ORMWrapper(object): return layer_version_object - def get_update_layer_object(self, layer_information): + def get_update_layer_object(self, layer_information, brbe): assert 'name' in layer_information assert 'local_path' in layer_information assert 'layer_index_url' in layer_information - layer_object, created = Layer.objects.get_or_create( + if brbe is None: + layer_object, created = Layer.objects.get_or_create( name=layer_information['name'], local_path=layer_information['local_path'], layer_index_url=layer_information['layer_index_url']) + return layer_object + else: + # we are under managed mode; we must match the layer used in the Project Layer + from bldcontrol.models import BuildEnvironment, BuildRequest + br, be = brbe.split(":") + + buildrequest = BuildRequest.objects.get(pk = br) + + # we might have a race condition here, as the project layers may change between the build trigger and the actual build execution + # but we can only match on the layer name, so the worst thing can happen is a mis-identification of the layer, not a total failure + layer_object = buildrequest.project.projectlayer_set.get(layercommit__layer__name=layer_information['name']).layercommit.layer + + return layer_object - return layer_object def save_target_file_information(self, build_obj, target_obj, filedata): assert isinstance(build_obj, Build)
@@ -689,7 +701,7 @@ class BuildInfoHelper(object): layerinfos = event._localdata self.internal_state['lvs'] = {} for layer in layerinfos: - self.internal_state['lvs'][self.orm_wrapper.get_update_layer_object(layerinfos[layer])] = layerinfos[layer]['version'] + self.internal_state['lvs'][self.orm_wrapper.get_update_layer_object(layerinfos[layer], self.brbe)] = layerinfos[layer]['version'] def store_started_build(self, event):
Signature · 1 line
1.9.1
PATCH 7/14 toaster: fix loadconf path calculation
In reply to Alex DAMIANFrom: Alexandru DAMIAN <[email protected]>
Fixing the path calculation for local layer sources, as the path need to be absolute.
Added tests for pieces of code.
--- .../bldcontrol/management/commands/loadconf.py | 42 +++++++++++++--------- lib/toaster/bldcontrol/tests.py | 19 ++++++++++ 2 files changed, 44 insertions(+), 17 deletions(-)
lib/toaster/bldcontrol/management/commands/loadconf.py +25 -17
@@ -5,20 +5,29 @@ import os from checksettings import DN +def _reduce_canon_path(path): + components = [] + for c in path.split("/"): + if c == "..": + del components[-1] + elif c == ".": + pass + else: + components.append(c) + if len(components) < 2: + components.append('') + return "/".join(components) + +def _get_id_for_sourcetype(s): + for i in LayerSource.SOURCE_TYPE: + if s == i[1]: + return i[0] + raise Exception("Could not find definition for sourcetype " + s) + class Command(BaseCommand): help = "Loads a toasterconf.json file in the database" args = "filepath" - def _reduce_canon_path(self, path): - components = [] - for c in path.split("/"): - if c == "..": - del components[-1] - elif c == ".": - pass - else: - components.append(c) - return "/".join(components) def _import_layer_config(self, filepath):
@@ -71,16 +80,13 @@ class Command(BaseCommand): assert 'name' in lsi assert 'branches' in lsi - def _get_id_for_sourcetype(s): - for i in LayerSource.SOURCE_TYPE: - if s == i[1]: - return i[0] - raise Exception("Could not find definition for sourcetype " + s) if _get_id_for_sourcetype(lsi['sourcetype']) == LayerSource.TYPE_LAYERINDEX or lsi['apiurl'].startswith("/"): apiurl = lsi['apiurl'] else: - apiurl = self._reduce_canon_path(os.path.join(DN(filepath), lsi['apiurl'])) + apiurl = _reduce_canon_path(os.path.join(DN(os.path.abspath(filepath)), lsi['apiurl'])) + + assert ((_get_id_for_sourcetype(lsi['sourcetype']) == LayerSource.TYPE_LAYERINDEX) or apiurl.startswith("/")), (lsi['sourcetype'],apiurl) try: ls = LayerSource.objects.get(sourcetype = _get_id_for_sourcetype(lsi['sourcetype']), apiurl = apiurl)
@@ -102,7 +108,7 @@ class Command(BaseCommand): if layerinfo['local_path'].startswith("/"): lo.local_path = layerinfo['local_path'] else: - lo.local_path = self._reduce_canon_path(os.path.join(DN(DN(DN(filepath))), layerinfo['local_path'])) + lo.local_path = _reduce_canon_path(os.path.join(ls.apiurl, layerinfo['local_path'])) if not os.path.exists(lo.local_path): raise Exception("Local layer path %s must exists." % lo.local_path)
@@ -110,6 +116,8 @@ class Command(BaseCommand): lo.vcs_url = layerinfo['vcs_url'] if layerinfo['vcs_url'].startswith("remote:"): lo.vcs_url = _read_git_url_from_local_repository(layerinfo['vcs_url']) + else: + lo.vcs_url = layerinfo['vcs_url'] if 'layer_index_url' in layerinfo: lo.layer_index_url = layerinfo['layer_index_url']
lib/toaster/bldcontrol/tests.py +19 -0
@@ -141,3 +141,22 @@ class RunBuildsCommandTests(TestCase): self.assertTrue(br.state == BuildRequest.REQ_INPROGRESS, "Request is not updated") # no more selections possible here self.assertRaises(IndexError, command._selectBuildRequest) + + +class UtilityTests(TestCase): + def test_reduce_path(self): + from bldcontrol.management.commands.loadconf import _reduce_canon_path, _get_id_for_sourcetype + + self.assertTrue( _reduce_canon_path("/") == "/") + self.assertTrue( _reduce_canon_path("/home/..") == "/") + self.assertTrue( _reduce_canon_path("/home/../ana") == "/ana") + self.assertTrue( _reduce_canon_path("/home/../ana/..") == "/") + self.assertTrue( _reduce_canon_path("/home/ana/mihai/../maria") == "/home/ana/maria") + + def test_get_id_for_sorucetype(self): + from bldcontrol.management.commands.loadconf import _reduce_canon_path, _get_id_for_sourcetype + self.assertTrue( _get_id_for_sourcetype("layerindex") == 1) + self.assertTrue( _get_id_for_sourcetype("local") == 0) + self.assertTrue( _get_id_for_sourcetype("imported") == 2) + with self.assertRaises(Exception): + _get_id_for_sourcetype("unknown")
Signature · 1 line
1.9.1
PATCH 8/14 toastergui: new project page
In reply to Alex DAMIANFrom: Alexandru DAMIAN <[email protected]>
Patch that brings in to new project page according to specifications.
[YOCTO #6596]
--- lib/toaster/toastergui/templates/base.html | 2 +- lib/toaster/toastergui/templates/newproject.html | 116 +++++++++++++++++------ lib/toaster/toastergui/views.py | 7 +- 3 files changed, 92 insertions(+), 33 deletions(-)
lib/toaster/toastergui/templates/base.html +1 -1
@@ -60,7 +60,7 @@ </a> {%if MANAGED %} <div class="btn-group pull-right"> - <a class="btn" href="{% url 'newproject' %}">New project</a> + <a class="btn" id="new-project-button" href="{% url 'newproject' %}">New project</a> </div> <!-- New build popover --> <div class="btn-group pull-right" id="new-build-button">
lib/toaster/toastergui/templates/newproject.html +86 -30
121 lines not on this page · raw message
lib/toaster/toastergui/views.py +5 -2
@@ -1894,9 +1894,12 @@ if toastermain.settings.MANAGED: 'email': request.user.email if request.user.is_authenticated() else '', 'username': request.user.username if request.user.is_authenticated() else '', 'releases': Release.objects.order_by("id"), - 'defaultbranch': ToasterSetting.objects.get(name = "DEFAULT_RELEASE").value, } + try: + context['defaultbranch'] = ToasterSetting.objects.get(name = "DEFAULT_RELEASE").value + except ToasterSetting.DoesNotExist: + pass if request.method == "GET": # render new project page
@@ -2156,7 +2159,7 @@ if toastermain.settings.MANAGED: def _lv_to_dict(x): - return {"id": x.pk, "name": x.layer.name, + return {"id": x.pk, "name": x.layer.name, "detail": "(" + x.layer.vcs_url + (")" if x.up_branch == None else " | "+x.up_branch.name+")"), "giturl": x.layer.vcs_url, "layerdetailurl" : reverse('layerdetails', args=(x.pk,))}
Signature · 1 line
1.9.1
PATCH 9/14 toaster: fix errors and warnings display
In reply to Alex DAMIANFrom: Belen Barros Pena <[email protected]>
In the 'Latest builds' section of the project page, show number of errors and number of warnings as we show them in the all builds page.
--- lib/toaster/toastergui/templates/project.html | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-)
lib/toaster/toastergui/templates/project.html +18 -2
@@ -178,8 +178,24 @@ vim: expandtab tabstop=2 {[b.build[0].completed_on|date:'dd/MM/yy HH:mm']} </ngif> </div> - <div class="span2"><span><a href="{[b.build[0].build_page_url]}#errors" class="lead error" ng-if="b.build[0].errors">{[b.build[0].errors]}</a></span></div> - <div class="span2"><span><a href="{[b.build[0].build_page_url]}#warnings" class="lead warning" ng-if="b.build[0].warnings">{[b.build[0].warnings]}</a></span></div> + <div class="span2"> + <ngif ng-if="b.build[0].errors"> + <span> + <i class="icon-minus-sign red lead"></i> + <a href="{[b.build[0].build_page_url]}#errors" class="lead error">{[b.build[0].errors]} + <ng-pluralize count="b.build[0].errors" when="{'1':'error','other':'errors'}"></ng-pluralize></a> + </span> + </ngif> + </div> + <div class="span2"> + <ngif ng-if="b.build[0].warnings"> + <span> + <i class="icon-warning-sign yellow lead"></i> + <a href="{[b.build[0].build_page_url]}#warnings" class="lead warning">{[b.build[0].warnings]} + <ng-pluralize count="b.build[0].warnings" when="{'1':'warning','other':'warnings'}"></ng-pluralize></a> + </span> + </ngif> + </div> <div> <span class="lead">Build time: <a href="{[b.build[0].build_time_page_url]}">{[b.build[0].build_time|timediff]}</a></span> <button class="btn pull-right" ng-class="{'Succeeded': 'btn-success', 'Failed': 'btn-danger'}[b.build[0].status]" ng-click="targetExistingBuild(b.targets)">Run again</button>
Signature · 1 line
1.9.1
PATCH 10/14 toaster: make 'latest builds' section consistent across pages
In reply to Alex DAMIANFrom: Belen Barros Pena <[email protected]>
Make sure that the 'latest builds' sections in the all builds page and the project page are identical:
* no icon to represent build outcome
* remove machine name
* show date only when the build is more than 24 hours old
* same date and time format
--- lib/toaster/toastergui/templates/mrb_section.html | 15 +++++++++++---- lib/toaster/toastergui/templatetags/projecttags.py | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-)
lib/toaster/toastergui/templates/mrb_section.html +11 -4
@@ -7,7 +7,7 @@ <div class="page-header top-air"> <h1> - Latest Builds + Latest builds </h1> </div> <div id="latest-builds">
@@ -18,16 +18,23 @@ {% endif %} <div class="row-fluid"> - <div class="span4 lead"> - {%if build.outcome == build.SUCCEEDED%}<i class="icon-ok-sign success"></i>{%elif build.outcome == build.FAILED%}<i class="icon-minus-sign error"></i>{%else%}{%endif%} + <div class="span3 lead"> {%if build.outcome == build.SUCCEEDED or build.outcome == build.FAILED %} <a href="{%url 'builddashboard' build.pk%}" class="{%if build.outcome == build.SUCCEEDED %}success{%else%}error{%endif%}"> {% endif %} - <span data-toggle="tooltip" {%if build.target_set.all.count > 1%}title="Targets: {%for target in build.target_set.all%}{{target.target}} {%endfor%}"{%endif%}>{{build.target_set.all.0.target}} {%if build.target_set.all.count > 1%}(+ {{build.target_set.all.count|add:"-1"}}){%endif%} {{build.machine}} ({{build.completed_on|naturaltime}})</span> + <span data-toggle="tooltip" {%if build.target_set.all.count > 1%}title="Targets: {%for target in build.target_set.all%}{{target.target}} {%endfor%}"{%endif%}>{{build.target_set.all.0.target}} {%if build.target_set.all.count > 1%}(+ {{build.target_set.all.count|add:"-1"}}){%endif%} + </span> {%if build.outcome == build.SUCCEEDED or build.outcome == build.FAILED %} </a> {% endif %} </div> + <div class="span2 lead"> + {% if build.completed_on|format_build_date %} + {{ build.completed_on|date:'d/m/y H:i' }} + {% else %} + {{ build.completed_on|date:'H:i' }} + {% endif %} + </div> {%if build.outcome == build.SUCCEEDED or build.outcome == build.FAILED %} <div class="span2 lead"> {% if build.errors_no %}
lib/toaster/toastergui/templatetags/projecttags.py +8 -0
@@ -268,3 +268,11 @@ def get_dict_value(dictionary, key): return dictionary[key] except (KeyError, IndexError): return '' + [email protected] +def format_build_date(completed_on): + now = timezone.now() + delta = now - completed_on + + if delta.days >= 1: + return True
Signature · 1 line
1.9.1
PATCH 11/14 toaster: fix padding of build notifications
In reply to Alex DAMIANFrom: Belen Barros Pena <[email protected]>
In managed mode, we show the name of the project in the build notifications of the all builds page. The way we show the project requires modifying the default padding of the .alert class. This patch makes sure the padding modification is only applied in managed mode, i.e., when the project name needs to be shown.
--- lib/toaster/toastergui/static/css/default.css | 4 ++++ lib/toaster/toastergui/templates/mrb_section.html | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-)
lib/toaster/toastergui/static/css/default.css +4 -0
@@ -136,6 +136,10 @@ select { width: auto; } .new-build .alert { margin-top: 10px; } .new-build .alert p { margin-top: 10px; } +/* styles for showing the project name in build mode */ +.project-name { padding-top: 0; } +.project-name .label { font-weight: normal; margin-bottom: 5px; margin-left: -15px; padding: 5px; } + /* Remove bottom margin for forms inside modal dialogs */ #dependencies_modal_form { margin-bottom: 0px; }
lib/toaster/toastergui/templates/mrb_section.html +2 -2
@@ -12,9 +12,9 @@ </div> <div id="latest-builds"> {% for build in mru %} - <div class="alert {%if build.outcome == build.SUCCEEDED%}alert-success{%elif build.outcome == build.FAILED%}alert-error{%else%}alert-info{%endif%}" style="padding-top: 0;"> + <div class="alert {%if build.outcome == build.SUCCEEDED%}alert-success{%elif build.outcome == build.FAILED%}alert-error{%else%}alert-info{%endif%} {% if MANAGED and build.project %}project-name{% endif %} "> {% if MANAGED and build.project %} - <span class="label {%if build.outcome == build.SUCCEEDED%}label-success{%elif build.outcome == build.FAILED%}label-danger{%else%}label-info{%endif%}" style="font-weight: normal; margin-bottom: 5px; margin-left:-15px; padding-top:5px;"> {{build.project.name}} </span> + <span class="label {%if build.outcome == build.SUCCEEDED%}label-success{%elif build.outcome == build.FAILED%}label-danger{%else%}label-info{%endif%}"> {{build.project.name}} </span> {% endif %} <div class="row-fluid">
Signature · 1 line
1.9.1
PATCH 12/14 toasterui: Compatibility patch for daisy and dizzy
In reply to Alex DAMIANFrom: Alexandru DAMIAN <[email protected]>
This patch brings in changes that allow a toasterUI coming in from 'master' branch to record data from a 'daisy' or 'dizzy' bitbake server.
This is needed to allow Toaster to record builds running on older branch releases.
--- lib/bb/ui/buildinfohelper.py | 43 ++++++++++++++++++++++++++----------------- lib/bb/ui/toasterui.py | 3 +-- 2 files changed, 27 insertions(+), 19 deletions(-)
lib/bb/ui/buildinfohelper.py +26 -17
@@ -695,10 +695,19 @@ class BuildInfoHelper(object): ################################ ## external available methods to store information + @staticmethod + def _get_data_from_event(event): + evdata = None + if '_localdata' in vars(event): + evdata = event._localdata + elif 'data' in vars(event): + evdata = event.data + else: + raise Exception("Event with neither _localdata or data properties") + return evdata def store_layer_info(self, event): - assert '_localdata' in vars(event) - layerinfos = event._localdata + layerinfos = BuildInfoHelper._get_data_from_event(event) self.internal_state['lvs'] = {} for layer in layerinfos: self.internal_state['lvs'][self.orm_wrapper.get_update_layer_object(layerinfos[layer], self.brbe)] = layerinfos[layer]['version']
@@ -732,15 +741,16 @@ class BuildInfoHelper(object): return self.brbe - def update_target_image_file(self, event): image_fstypes = self.server.runCommand(["getVariable", "IMAGE_FSTYPES"])[0] + evdata = BuildInfoHelper._get_data_from_event(event) + for t in self.internal_state['targets']: if t.is_image == True: - output_files = list(event._localdata.viewkeys()) + output_files = list(evdata.viewkeys()) for output in output_files: if t.target in output and output.split('.rootfs.')[1] in image_fstypes: - self.orm_wrapper.save_target_image_file_information(t, output, event._localdata[output]) + self.orm_wrapper.save_target_image_file_information(t, output, evdata[output]) def update_build_information(self, event, errors, warnings, taskfailures): if 'build' in self.internal_state:
@@ -748,8 +758,8 @@ class BuildInfoHelper(object): def store_license_manifest_path(self, event): - deploy_dir = event._localdata['deploy_dir'] - image_name = event._localdata['image_name'] + deploy_dir = BuildInfoHelper._get_data_from_event(event)['deploy_dir'] + image_name = BuildInfoHelper._get_data_from_event(event)['image_name'] path = deploy_dir + "/licenses/" + image_name + "/" for target in self.internal_state['targets']: if target.target in image_name:
@@ -797,7 +807,7 @@ class BuildInfoHelper(object): def store_tasks_stats(self, event): - for (taskfile, taskname, taskstats, recipename) in event._localdata: + for (taskfile, taskname, taskstats, recipename) in BuildInfoHelper._get_data_from_event(event): localfilepath = taskfile.split(":")[-1] assert localfilepath.startswith("/")
@@ -812,7 +822,8 @@ class BuildInfoHelper(object): task_information['task_name'] = taskname task_information['cpu_usage'] = taskstats['cpu_usage'] task_information['disk_io'] = taskstats['disk_io'] - task_information['elapsed_time'] = taskstats['elapsed_time'] + if 'elapsed_time' in taskstats: + task_information['elapsed_time'] = taskstats['elapsed_time'] task_obj = self.orm_wrapper.get_update_task_object(task_information, True) # must exist def update_and_store_task(self, event):
@@ -870,7 +881,7 @@ class BuildInfoHelper(object): def store_missed_state_tasks(self, event): - for (fn, taskname, taskhash, sstatefile) in event._localdata['missed']: + for (fn, taskname, taskhash, sstatefile) in BuildInfoHelper._get_data_from_event(event)['missed']: identifier = fn + taskname + "_setscene" recipe_information = self._get_recipe_information_from_taskfile(fn)
@@ -888,7 +899,7 @@ class BuildInfoHelper(object): self.orm_wrapper.get_update_task_object(task_information) - for (fn, taskname, taskhash, sstatefile) in event._localdata['found']: + for (fn, taskname, taskhash, sstatefile) in BuildInfoHelper._get_data_from_event(event)['found']: identifier = fn + taskname + "_setscene" recipe_information = self._get_recipe_information_from_taskfile(fn)
@@ -904,15 +915,14 @@ class BuildInfoHelper(object): def store_target_package_data(self, event): - assert '_localdata' in vars(event) # for all image targets for target in self.internal_state['targets']: if target.is_image: try: - pkgdata = event._localdata['pkgdata'] - imgdata = event._localdata['imgdata'][target.target] + pkgdata = BuildInfoHelper._get_data_from_event(event)['pkgdata'] + imgdata = BuildInfoHelper._get_data_from_event(event)['imgdata'][target.target] self.orm_wrapper.save_target_package_information(self.internal_state['build'], target, imgdata, pkgdata, self.internal_state['recipes']) - filedata = event._localdata['filedata'][target.target] + filedata = BuildInfoHelper._get_data_from_event(event)['filedata'][target.target] self.orm_wrapper.save_target_file_information(self.internal_state['build'], target, filedata) except KeyError: # we must have not got the data for this image, nothing to save
@@ -1026,8 +1036,7 @@ class BuildInfoHelper(object): def store_build_package_information(self, event): - assert '_localdata' in vars(event) - package_info = event._localdata + package_info = BuildInfoHelper._get_data_from_event(event) self.orm_wrapper.save_build_package_information(self.internal_state['build'], package_info, self.internal_state['recipes'],
lib/bb/ui/toasterui.py +1 -2
@@ -296,10 +296,9 @@ def main(server, eventHandler, params ): pass except Exception as e: # print errors to log - logger.error(e) import traceback exception_data = traceback.format_exc() - print(exception_data) + logger.error("%s\n%s" % (e, exception_data)) # save them to database, if possible; if it fails, we already logged to console. try:
Signature · 1 line
1.9.1
PATCH 13/14 toaster: release name consistency
In reply to Alex DAMIANFrom: Belen Barros Pena <[email protected]>
Small changes to the project, new project and all layers pages to ensure consitency in release naming across the interface.
The changes are:
* In the new project page, change the label 'release version' to 'release'
* In the new project page, sort the releases in the dropdown menu in ascending alphabetical order
* In the new project page, remove the release name that was showing between brackets after the release description in the dropdown menu
* In the project page, make sure the release information shows the release description field instead of the release name, to keep consistency with the new project page
* In the all layers page, provide some help text for the branch 'HEAD'
--- lib/toaster/toastergui/templates/layers.html | 9 ++++++++- lib/toaster/toastergui/templates/newproject.html | 4 ++-- lib/toaster/toastergui/templates/project.html | 4 ++-- lib/toaster/toastergui/views.py | 4 ++-- 4 files changed, 14 insertions(+), 7 deletions(-)
lib/toaster/toastergui/templates/layers.html +8 -1
@@ -52,7 +52,14 @@ <a target="_blank" href="{{ o.get_vcs_dirpath_link_url }}"><i class="icon-share get-info"></i></a> {% endif %} </td> - <td class="branch">{% if o.branch %}{{o.branch}}{% else %}{{o.up_branch.name}}{% endif %}</td> + <td class="branch"> + {% if o.branch %} + {{o.branch}} + {% else %} + {{o.up_branch.name}} + <i class="icon-question-sign get-help hover-help" title="Your builds will use the tip of the branch you have cloned or downloaded to your computer, so nothing will be fetched"></i> + {% endif %} + </td> <td class="dependencies"> {% with ods=o.dependencies.all%} {% if ods.count %}
lib/toaster/toastergui/templates/newproject.html +2 -2
@@ -18,12 +18,12 @@ <input type="text" class="input-xlarge" required id="new-project-name" name="projectname"> {% if releases.count > 1 %} <label class="project-form"> - Release version + Release <i class="icon-question-sign get-help" title="The version of the build system you want to use"></i> </label> <select name="projectversion" id="projectversion"> {% for release in releases %} - <option value="{{release.id}}"{%if projectversion == release.id %} selected{%endif%}>{{release.description}} ({{release.name}})</option> + <option value="{{release.id}}"{%if projectversion == release.id %} selected{%endif%}>{{release.description}}</option> {% endfor %} </select> {% for release in releases %}
lib/toaster/toastergui/templates/project.html +2 -2
@@ -350,13 +350,13 @@ vim: expandtab tabstop=2 <i class="icon-question-sign get-help heading-help" title="The version of the build system you want to use"></i> </h3> <p class="lead" id="change-project-version-opposite"> - <span id="project-version">{[project.release.name]}</span> + <span id="project-version">{[project.release.desc]}</span> <i id="change-version" class="icon-pencil" ng-click="toggle('#change-project-version')" ></i> </p> <div class="div-inline" id="change-project-version" style="display:none;"> <form ng-submit="test('#change-project-version')" class="input-append"> <select id="select-version" ng-model="projectVersion"> - <option ng-repeat="r in releases" value="{[r.id]}" ng-selected="r.id == project.release.id">{[r.name]}</option> + <option ng-repeat="r in releases" value="{[r.id]}" ng-selected="r.id == project.release.id">{[r.description]}</option> </select> <input type="submit" class="btn" style="margin-left:5px;" value="Save" ng-disabled="project.release.id == projectVersion"/> <input type="reset" class="btn btn-link" value="Cancel" ng-click="toggle('#change-project-version')" ng-disabled="project.release.id == projectVersion"/>
lib/toaster/toastergui/views.py +2 -2
@@ -1893,7 +1893,7 @@ if toastermain.settings.MANAGED: context = { 'email': request.user.email if request.user.is_authenticated() else '', 'username': request.user.username if request.user.is_authenticated() else '', - 'releases': Release.objects.order_by("id"), + 'releases': Release.objects.order_by("description"), } try:
@@ -2016,7 +2016,7 @@ if toastermain.settings.MANAGED: prj.projectlayer_set.all().order_by("id")), "targets" : map(lambda x: {"target" : x.target, "task" : x.task, "pk": x.pk}, prj.projecttarget_set.all()), "freqtargets": freqtargets, - "releases": map(lambda x: {"id": x.pk, "name": x.name}, Release.objects.all()), + "releases": map(lambda x: {"id": x.pk, "name": x.name, "description":x.description}, Release.objects.all()), } try: context["machine"] = {"name": prj.projectvariable_set.get(name="MACHINE").value}
Signature · 1 line
1.9.1
PATCH 14/14 toaster: remove the word 'project' from layers and machine
In reply to Alex DAMIANFrom: Belen Barros Pena <[email protected]>
Remove the word 'project' from the 'Layers' and 'Machine' sections of the project page, following feedback from the QA team, who suggested that the word 'project' raised questions regarding the scope of the configuration,
--- lib/toaster/toastergui/templates/project.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-)
lib/toaster/toastergui/templates/project.html +2 -2
@@ -227,7 +227,7 @@ vim: expandtab tabstop=2 <!-- project layers --> <div id="layer-container" class="well well-transparent span4"> <h3> - Project layers <span class="muted counter">({[layers.length]})</span> + Layers <span class="muted counter">({[layers.length]})</span> <i class="icon-question-sign get-help heading-help" title="OpenEmbedded organises metadata into modules called 'layers'. Layers allow you to isolate different types of customizations from each other. <a href='http://www.yoctoproject.org/docs/current/dev-manual/dev-manual.html#understanding-and-creating-layers' target='_blank'>More on layers</a>"></i> </h3> <div class="alert" ng-if="!layers.length">
@@ -292,7 +292,7 @@ vim: expandtab tabstop=2 <!-- project configuration --> <div id="machine-distro" class="well well-transparent span4"> <h3> - Project machine + Machine <i class="icon-question-sign get-help heading-help" title="The machine is the hardware for which you want to build. You can only set one machine per project"></i> </h3> <p class="lead" id="select-machine-opposite">
Signature · 1 line
1.9.1