=== modified file 'dashboard_app/admin.py'
--- dashboard_app/admin.py	2012-03-16 16:57:24 +0000
+++ dashboard_app/admin.py	2012-07-12 05:42:59 +0000
@@ -31,6 +31,9 @@
     BundleDeserializationError,
     BundleStream,
     HardwareDevice,
+    Image,
+    ImageAttribute,
+    ImageSet,
     NamedAttribute,
     SoftwarePackage,
     SoftwareSource,
@@ -160,11 +163,30 @@
     list_display = ('__unicode__', 'project')
 
 
+class ImageAttributeInline(admin.TabularInline):
+    model = ImageAttribute
+    verbose_name = 'required metadata attribute'
+    verbose_name_plural = 'required metadata attributes'
+
+
+class ImageAdmin(admin.ModelAdmin):
+    filter_horizontal = ['bundle_streams']
+    inlines = [ImageAttributeInline]
+    save_as = True
+
+
+class ImageSetAdmin(admin.ModelAdmin):
+    filter_horizontal = ['images']
+    save_as = True
+
+
 admin.site.register(Attachment)
 admin.site.register(Bundle, BundleAdmin)
 admin.site.register(BundleDeserializationError, BundleDeserializationErrorAdmin)
 admin.site.register(BundleStream, BundleStreamAdmin)
 admin.site.register(HardwareDevice, HardwareDeviceAdmin)
+admin.site.register(Image, ImageAdmin)
+admin.site.register(ImageSet, ImageSetAdmin)
 admin.site.register(SoftwarePackage, SoftwarePackageAdmin)
 admin.site.register(SoftwareSource, SoftwareSourceAdmin)
 admin.site.register(Test, TestAdmin)

=== modified file 'dashboard_app/extension.py'
--- dashboard_app/extension.py	2012-03-26 04:05:20 +0000
+++ dashboard_app/extension.py	2012-07-13 01:22:03 +0000
@@ -44,7 +44,9 @@
             Menu("Bundle Streams", reverse("dashboard_app.views.bundle_stream_list")),
             Menu("Tests", reverse("dashboard_app.views.test_list")),
             Menu("Data Views", reverse("dashboard_app.views.data_view_list")),
-            Menu("Reports", reverse("dashboard_app.views.report_list"))]
+            Menu("Reports", reverse("dashboard_app.views.report_list")),
+            Menu("Image Reports", reverse("dashboard_app.views.image_report_list")),
+            ]
         return menu
 
     @property

=== added file 'dashboard_app/migrations/0014_auto__add_imageset__add_image__add_imageattribute.py'
--- dashboard_app/migrations/0014_auto__add_imageset__add_image__add_imageattribute.py	1970-01-01 00:00:00 +0000
+++ dashboard_app/migrations/0014_auto__add_imageset__add_image__add_imageattribute.py	2012-07-12 04:58:23 +0000
@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Adding model 'ImageSet'
+        db.create_table('dashboard_app_imageset', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=1024)),
+        ))
+        db.send_create_signal('dashboard_app', ['ImageSet'])
+
+        # Adding M2M table for field images on 'ImageSet'
+        db.create_table('dashboard_app_imageset_images', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('imageset', models.ForeignKey(orm['dashboard_app.imageset'], null=False)),
+            ('image', models.ForeignKey(orm['dashboard_app.image'], null=False))
+        ))
+        db.create_unique('dashboard_app_imageset_images', ['imageset_id', 'image_id'])
+
+        # Adding model 'Image'
+        db.create_table('dashboard_app_image', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=1024)),
+            ('build_number_attribute', self.gf('django.db.models.fields.CharField')(max_length=1024)),
+        ))
+        db.send_create_signal('dashboard_app', ['Image'])
+
+        # Adding M2M table for field bundle_streams on 'Image'
+        db.create_table('dashboard_app_image_bundle_streams', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('image', models.ForeignKey(orm['dashboard_app.image'], null=False)),
+            ('bundlestream', models.ForeignKey(orm['dashboard_app.bundlestream'], null=False))
+        ))
+        db.create_unique('dashboard_app_image_bundle_streams', ['image_id', 'bundlestream_id'])
+
+        # Adding model 'ImageAttribute'
+        db.create_table('dashboard_app_imageattribute', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('name', self.gf('django.db.models.fields.CharField')(max_length=1024)),
+            ('value', self.gf('django.db.models.fields.CharField')(max_length=1024)),
+            ('image', self.gf('django.db.models.fields.related.ForeignKey')(related_name='required_attributes', to=orm['dashboard_app.Image'])),
+        ))
+        db.send_create_signal('dashboard_app', ['ImageAttribute'])
+
+
+    def backwards(self, orm):
+        # Deleting model 'ImageSet'
+        db.delete_table('dashboard_app_imageset')
+
+        # Removing M2M table for field images on 'ImageSet'
+        db.delete_table('dashboard_app_imageset_images')
+
+        # Deleting model 'Image'
+        db.delete_table('dashboard_app_image')
+
+        # Removing M2M table for field bundle_streams on 'Image'
+        db.delete_table('dashboard_app_image_bundle_streams')
+
+        # Deleting model 'ImageAttribute'
+        db.delete_table('dashboard_app_imageattribute')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'dashboard_app.attachment': {
+            'Meta': {'object_name': 'Attachment'},
+            'content': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'null': 'True'}),
+            'content_filename': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'mime_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
+            'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'public_url': ('django.db.models.fields.URLField', [], {'max_length': '512', 'blank': 'True'})
+        },
+        'dashboard_app.bundle': {
+            'Meta': {'ordering': "['-uploaded_on']", 'object_name': 'Bundle'},
+            '_gz_content': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'null': 'True', 'db_column': "'gz_content'"}),
+            '_raw_content': ('django.db.models.fields.files.FileField', [], {'max_length': '100', 'null': 'True', 'db_column': "'content'"}),
+            'bundle_stream': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'bundles'", 'to': "orm['dashboard_app.BundleStream']"}),
+            'content_filename': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'content_sha1': ('django.db.models.fields.CharField', [], {'max_length': '40', 'unique': 'True', 'null': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_deserialized': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'uploaded_by': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'uploaded_bundles'", 'null': 'True', 'to': "orm['auth.User']"}),
+            'uploaded_on': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'})
+        },
+        'dashboard_app.bundledeserializationerror': {
+            'Meta': {'object_name': 'BundleDeserializationError'},
+            'bundle': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'deserialization_error'", 'unique': 'True', 'primary_key': 'True', 'to': "orm['dashboard_app.Bundle']"}),
+            'error_message': ('django.db.models.fields.CharField', [], {'max_length': '1024'}),
+            'traceback': ('django.db.models.fields.TextField', [], {'max_length': '32768'})
+        },
+        'dashboard_app.bundlestream': {
+            'Meta': {'object_name': 'BundleStream'},
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.Group']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_public': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+            'pathname': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128'}),
+            'slug': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'})
+        },
+        'dashboard_app.hardwaredevice': {
+            'Meta': {'object_name': 'HardwareDevice'},
+            'description': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'device_type': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        'dashboard_app.image': {
+            'Meta': {'object_name': 'Image'},
+            'build_number_attribute': ('django.db.models.fields.CharField', [], {'max_length': '1024'}),
+            'bundle_streams': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['dashboard_app.BundleStream']", 'symmetrical': 'False'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '1024'})
+        },
+        'dashboard_app.imageattribute': {
+            'Meta': {'object_name': 'ImageAttribute'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'image': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'required_attributes'", 'to': "orm['dashboard_app.Image']"}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '1024'}),
+            'value': ('django.db.models.fields.CharField', [], {'max_length': '1024'})
+        },
+        'dashboard_app.imageset': {
+            'Meta': {'object_name': 'ImageSet'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'images': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['dashboard_app.Image']", 'symmetrical': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '1024'})
+        },
+        'dashboard_app.namedattribute': {
+            'Meta': {'unique_together': "(('object_id', 'name'),)", 'object_name': 'NamedAttribute'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.TextField', [], {}),
+            'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'value': ('django.db.models.fields.TextField', [], {})
+        },
+        'dashboard_app.softwarepackage': {
+            'Meta': {'unique_together': "(('name', 'version'),)", 'object_name': 'SoftwarePackage'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'version': ('django.db.models.fields.CharField', [], {'max_length': '128'})
+        },
+        'dashboard_app.softwarepackagescratch': {
+            'Meta': {'object_name': 'SoftwarePackageScratch'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'version': ('django.db.models.fields.CharField', [], {'max_length': '128'})
+        },
+        'dashboard_app.softwaresource': {
+            'Meta': {'object_name': 'SoftwareSource'},
+            'branch_revision': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'branch_url': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
+            'branch_vcs': ('django.db.models.fields.CharField', [], {'max_length': '10'}),
+            'commit_timestamp': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'project_name': ('django.db.models.fields.CharField', [], {'max_length': '32'})
+        },
+        'dashboard_app.tag': {
+            'Meta': {'object_name': 'Tag'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '256'})
+        },
+        'dashboard_app.test': {
+            'Meta': {'object_name': 'Test'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+            'test_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '64'})
+        },
+        'dashboard_app.testcase': {
+            'Meta': {'unique_together': "(('test', 'test_case_id'),)", 'object_name': 'TestCase'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'test': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'test_cases'", 'to': "orm['dashboard_app.Test']"}),
+            'test_case_id': ('django.db.models.fields.TextField', [], {}),
+            'units': ('django.db.models.fields.TextField', [], {'blank': 'True'})
+        },
+        'dashboard_app.testingeffort': {
+            'Meta': {'object_name': 'TestingEffort'},
+            'description': ('django.db.models.fields.TextField', [], {}),
+            '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', [], {'related_name': "'testing_efforts'", 'to': "orm['lava_projects.Project']"}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'testing_efforts'", 'symmetrical': 'False', 'to': "orm['dashboard_app.Tag']"})
+        },
+        'dashboard_app.testresult': {
+            'Meta': {'ordering': "('_order',)", 'object_name': 'TestResult'},
+            '_order': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'filename': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'lineno': ('django.db.models.fields.PositiveIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'measurement': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '20', 'decimal_places': '10', 'blank': 'True'}),
+            'message': ('django.db.models.fields.TextField', [], {'max_length': '1024', 'null': 'True', 'blank': 'True'}),
+            'microseconds': ('django.db.models.fields.BigIntegerField', [], {'null': 'True', 'blank': 'True'}),
+            'relative_index': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'result': ('django.db.models.fields.PositiveSmallIntegerField', [], {}),
+            'test_case': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'test_results'", 'null': 'True', 'to': "orm['dashboard_app.TestCase']"}),
+            'test_run': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'test_results'", 'to': "orm['dashboard_app.TestRun']"}),
+            'timestamp': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'})
+        },
+        'dashboard_app.testrun': {
+            'Meta': {'ordering': "['-import_assigned_date']", 'object_name': 'TestRun'},
+            'analyzer_assigned_date': ('django.db.models.fields.DateTimeField', [], {}),
+            'analyzer_assigned_uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'}),
+            'bundle': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'test_runs'", 'to': "orm['dashboard_app.Bundle']"}),
+            'devices': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'test_runs'", 'blank': 'True', 'to': "orm['dashboard_app.HardwareDevice']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'import_assigned_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'packages': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'test_runs'", 'blank': 'True', 'to': "orm['dashboard_app.SoftwarePackage']"}),
+            'sources': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'test_runs'", 'blank': 'True', 'to': "orm['dashboard_app.SoftwareSource']"}),
+            'sw_image_desc': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+            'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'test_runs'", 'blank': 'True', 'to': "orm['dashboard_app.Tag']"}),
+            'test': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'test_runs'", 'to': "orm['dashboard_app.Test']"}),
+            'time_check_performed': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
+        },
+        'dashboard_app.testrundenormalization': {
+            'Meta': {'object_name': 'TestRunDenormalization'},
+            'count_fail': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'count_pass': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'count_skip': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'count_unknown': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'test_run': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'denormalization'", 'unique': 'True', 'primary_key': 'True', 'to': "orm['dashboard_app.TestRun']"})
+        },
+        'lava_projects.project': {
+            'Meta': {'object_name': 'Project'},
+            'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.Group']", 'null': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'identifier': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}),
+            'is_aggregate': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_public': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'registered_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'projects'", 'to': "orm['auth.User']"}),
+            'registered_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'})
+        }
+    }
+
+    complete_apps = ['dashboard_app']
\ No newline at end of file

=== modified file 'dashboard_app/models.py'
--- dashboard_app/models.py	2012-03-26 04:00:57 +0000
+++ dashboard_app/models.py	2012-07-16 22:00:15 +0000
@@ -1412,3 +1412,83 @@
         return TestRun.objects.order_by(
         ).filter(
             tags__in=self.tags.all())
+
+
+class ImageAttribute(models.Model):
+
+    name = models.CharField(max_length=1024)
+    value = models.CharField(max_length=1024)
+
+    image = models.ForeignKey("Image", related_name="required_attributes")
+
+    def __unicode__(self):
+        return '%s = %s' % (self.name, self.value)
+
+class Image(models.Model):
+
+    name = models.CharField(max_length=1024, unique=True)
+
+    build_number_attribute = models.CharField(max_length=1024)
+
+    bundle_streams = models.ManyToManyField(BundleStream)
+
+    def __unicode__(self):
+        return self.name
+
+    def get_bundles(self, user):
+        accessible_bundles = BundleStream.objects.accessible_by_principal(
+            user)
+        args = [models.Q(bundle_stream__in=accessible_bundles)]
+        if self.bundle_streams.exists():
+            args += [models.Q(bundle_stream__in=self.bundle_streams.all())]
+        bundles = Bundle.objects.filter(*args)
+
+        # This is a little tricky.  We want to AND together the conditions
+        # that the attribute matches, but with the Django ORM we can only join
+        # the attribute table once per query so we put each condition in a
+        # nested query, so for example instead of something like this:
+        #
+        # select * from bundle
+        #  where <bundle.testrun.name is vexpress>
+        #    and <bundle.testrun.image_type = 'desktop';
+        #
+        # we generate this:
+        #
+        # select * from bundle
+        #  where <bundle.testrun.name is vexpress>
+        #    and bundle.id in
+        #      (select * from bundle
+        #       where <bundle.testrun.image_type = 'desktop');
+        #
+        # (additionally, we only consider the lava testrun to avoid returning
+        # bundles repeatedly).
+
+        for attr in self.required_attributes.all():
+            bundles = Bundle.objects.filter(
+                id__in=bundles.values_list('id'),
+                test_runs__test__test_id='lava',
+                test_runs__attributes__name=attr.name,
+                test_runs__attributes__value=attr.value)
+
+        return bundles
+
+    def get_latest_bundles(self, user, count):
+        return Bundle.objects.filter(
+            id__in=self.get_bundles(user).values('id'),
+            test_runs__test__test_id='lava',
+            test_runs__attributes__name=self.build_number_attribute).extra(
+            select={
+                'build_number': 'cast("dashboard_app_namedattribute"."value" as int)'
+                }).extra(
+            order_by=['-build_number'],
+            )[:count]
+
+
+class ImageSet(models.Model):
+
+    name = models.CharField(max_length=1024, unique=True)
+
+    images = models.ManyToManyField(Image)
+
+    def __unicode__(self):
+        return self.name

=== added file 'dashboard_app/static/css/image-report.css'
--- dashboard_app/static/css/image-report.css	1970-01-01 00:00:00 +0000
+++ dashboard_app/static/css/image-report.css	2012-07-12 22:36:31 +0000
@@ -0,0 +1,39 @@
+#outer-table, .inner-table {
+    border-collapse: collapse;
+}
+#outer-table > tbody > tr > td {
+    padding: 0;
+    vertical-align: top;
+}
+#outer-table th {
+    text-align: left;
+}
+.inner-table td, .inner-table th {
+    min-width: 25ex;
+    padding: 3px 4px;
+    border: thin solid black;
+}
+.inner-table td.present a {
+    color: #00a;
+    text-decoration: none;
+}
+.inner-table td.present a:hover {
+    text-decoration: underline;
+}
+.inner-table td.pass {
+    background-color: #4e4;
+}
+.inner-table td.fail {
+    background-color: #e44;
+}
+.inner-table td.missing {
+    background-color: #bbb;
+}
+#scroller {
+    width: 800px;
+    overflow-x: scroll;
+    overflow-y: visible;
+}
+#scroller td:first-child, #scroller th:first-child {
+    border-left: none;
+}

=== added file 'dashboard_app/static/js/image-report.js'
--- dashboard_app/static/js/image-report.js	1970-01-01 00:00:00 +0000
+++ dashboard_app/static/js/image-report.js	2012-07-13 04:21:42 +0000
@@ -0,0 +1,23 @@
+function _resize() {
+    // I couldn't figure out how to do this in CSS: resize the table
+    // so that it takes as much space as it can without expanding the
+    // page horizontally.
+    var space = parseInt($("#lava-breadcrumbs").outerWidth() - $("#outer-table").outerWidth());
+    space -= $("#lava-content").outerWidth() - $("#lava-content").width();
+    var table = $("#results-table"), scroller=$("#scroller");
+    var atRight = scroller.width() + scroller.scrollLeft() >= table.attr('scrollWidth');
+    scroller.width(scroller.width() + space);
+    if (atRight) scroller.scrollLeft(table.attr('scrollWidth'));
+}
+// Hook up the event and run resize ASAP (looks jumpy in FF if you
+// don't run it here).
+$(window).ready(
+    function () {
+      $(window).resize(_resize);
+      $("#scroller").scrollLeft(100000);
+      _resize();
+    });
+// Because what resize does depends on the final sizes of elements,
+// run it again after everything is loaded (things end up wrong in
+// chromium if you don't do this).
+$(window).load(_resize);

=== added file 'dashboard_app/templates/dashboard_app/image-report.html'
--- dashboard_app/templates/dashboard_app/image-report.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/image-report.html	2012-07-12 22:42:42 +0000
@@ -0,0 +1,79 @@
+{% extends "dashboard_app/_content.html" %}
+
+{% block extrahead %}
+{{ block.super }}
+<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}dashboard_app/css/image-report.css"/>
+<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/image-report.js"></script>
+{% endblock %}
+
+{% block content %}
+<h1>Image Report: {{ image.name }}</h1>
+
+<table id="outer-table">
+  <tr>
+    <td>
+      <table id="test-run-names" class="inner-table">
+        <thead>
+          <tr>
+            <th>
+              Build Number
+            </th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td>
+              Date
+            </td>
+          </tr>
+          {% for test_run_name in test_run_names %}
+          <tr>
+            <td>
+              {{ test_run_name }}
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </td>
+    <td>
+      <div id="scroller">
+        <table id="results-table" class="inner-table">
+          <thead>
+            <tr>
+              {% for bundle in bundles %}
+              <th>
+                <a href="{{ bundle.link }}">{{ bundle.number }}</a>
+              </th>
+              {% endfor %}
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              {% for bundle in bundles %}
+              <td>
+                {{ bundle.date|date }}
+              </td>
+              {% endfor %}
+            </tr>
+            {% for row_data in table_data %}
+            <tr>
+              {% for result in row_data %}
+              <td class="{{ result.cls }}">
+                {% if result.present %}
+                <a href="{{ result.link }}"> {{ result.passes }}/{{ result.total }} </a>
+                {% else %}
+                &mdash;
+                {% endif %}
+              </td>
+              {% endfor %}
+            </tr>
+            {% endfor %}
+          </tbody>
+        </table>
+      </div>
+    </td>
+  </tr>
+</table>
+
+{% endblock %}

=== added file 'dashboard_app/templates/dashboard_app/image-reports.html'
--- dashboard_app/templates/dashboard_app/image-reports.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/image-reports.html	2012-07-12 06:16:07 +0000
@@ -0,0 +1,21 @@
+{% extends "dashboard_app/_content.html" %}
+
+{% block content %}
+<h1>Image Reports</h1>
+
+{% for imageset in imagesets %}
+<h2>{{ imageset.name }}</h2>
+<ul>
+  {% for image in imageset.images %}
+  <li>
+    {% if image.bundle_count %}
+    <a href="{% url dashboard_app.views.image_report_detail name=image.name %}">{{ image.name }}</a>
+    ({{ image.bundle_count }} results)
+    {% else %}
+    {{ image.name }} ({{ image.bundle_count }} results)
+    {% endif %}
+  </li>
+  {% endfor %}
+</ul>
+{% endfor %}
+{% endblock %}

=== modified file 'dashboard_app/urls.py'
--- dashboard_app/urls.py	2012-03-26 04:00:57 +0000
+++ dashboard_app/urls.py	2012-07-12 05:40:54 +0000
@@ -70,4 +70,6 @@
     url(r'^efforts/(?P<pk>[0-9]+)/$', 'testing_effort_detail'),
     url(r'^efforts/(?P<pk>[0-9]+)/update/$', 'testing_effort_update'),
     url(r'^efforts/(?P<project_identifier>[a-z0-9-]+)/\+new/$', 'testing_effort_create'),
+    url(r'^image-reports/$', 'image_report_list'),
+    url(r'^image-reports/(?P<name>[A-Za-z0-9_-]+)$', 'image_report_detail'),
 )

=== modified file 'dashboard_app/views.py'
--- dashboard_app/views.py	2012-06-14 05:50:23 +0000
+++ dashboard_app/views.py	2012-07-13 01:20:45 +0000
@@ -49,6 +49,8 @@
     BundleStream,
     DataReport,
     DataView,
+    Image,
+    ImageSet,
     Tag,
     Test,
     TestResult,
@@ -849,3 +851,119 @@
             pk=effort.pk)
     })
     return HttpResponse(t.render(c))
+
+
+@BreadCrumb("Image Reports", parent=index)
+def image_report_list(request):
+    imagesets = ImageSet.objects.all()
+    imagesets_data = []
+    for imageset in imagesets:
+        images_data = []
+        for image in imageset.images.all():
+            image_data = {
+                'name': image.name,
+                'bundle_count': image.get_bundles(request.user).count(),
+                }
+            images_data.append(image_data)
+        imageset_data = {
+            'name': imageset,
+            'images': images_data,
+            }
+        imagesets_data.append(imageset_data)
+    return render_to_response(
+        "dashboard_app/image-reports.html", {
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(image_report_list),
+            'imagesets': imagesets_data,
+        }, RequestContext(request))
+
+
+@BreadCrumb("{name}", parent=image_report_list, needs=['name'])
+def image_report_detail(request, name):
+
+    image = Image.objects.get(name=name)
+
+    # We are aiming to produce a table like this:
+
+    # Build Number | 23         | ... | 40         |
+    # Date         | YYYY-MM-DD | ... | YYYY-MM-DD |
+    # lava         | 1/3        | ... | 4/5        |
+    # cts          | 100/100    | ... | 88/100     |
+    # ...          | ...        | ... | ...        |
+    # skia         | 1/2        | ... | 3/3        |
+
+    # Data processing proceeds in 3 steps:
+
+    # 1) Get the bundles/builds.  Image.get_latest_bundles() does the hard
+    # work here and then we just peel off the data we need from the bundles.
+
+    # 2) Get all the test runs we are interested in, extract the data we
+    # need from them and associate them with the corresponding bundles.
+
+    # 3) Organize the data so that it's natural for rendering the table
+    # (basically transposing it from being bundle -> testrun -> result to
+    # testrun -> bundle -> result).
+
+    bundles = image.get_latest_bundles(request.user, 50)
+
+    bundle_id_to_data = {}
+    for bundle in bundles:
+        bundle_id_to_data[bundle.id] = dict(
+            number=bundle.build_number,
+            date=bundle.uploaded_on,
+            test_runs={},
+            link=bundle.get_permalink(),
+            )
+
+    test_runs = TestRun.objects.filter(
+        bundle_id__in=list(bundle_id_to_data),
+        ).select_related(
+        'bundle', 'denormalization', 'test')
+
+    test_run_names = set()
+    for test_run in test_runs:
+        name = test_run.test.test_id
+        denorm = test_run.denormalization
+        if denorm.count_pass == denorm.count_all():
+            cls = 'present pass'
+        else:
+            cls = 'present fail'
+        test_run_data = dict(
+            present=True,
+            cls=cls,
+            uuid=test_run.analyzer_assigned_uuid,
+            passes=denorm.count_pass,
+            total=denorm.count_all(),
+            link=test_run.get_permalink(),
+            )
+        bundle_id_to_data[test_run.bundle.id]['test_runs'][name] = test_run_data
+        if name != 'lava':
+            test_run_names.add(name)
+
+    test_run_names = sorted(test_run_names)
+    test_run_names.insert(0, 'lava')
+
+    bundles = sorted(bundle_id_to_data.values(), key=lambda d:d['number'])
+
+    table_data = []
+
+    for test_run_name in test_run_names:
+        row_data = []
+        for bundle in bundles:
+            test_run_data = bundle['test_runs'].get(test_run_name)
+            if not test_run_data:
+                test_run_data = dict(
+                    present=False,
+                    cls='missing',
+                    )
+            row_data.append(test_run_data)
+        table_data.append(row_data)
+
+    return render_to_response(
+        "dashboard_app/image-report.html", {
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(
+                image_report_detail, name=image.name),
+            'image': image,
+            'bundles': bundles,
+            'table_data': table_data,
+            'test_run_names': test_run_names,
+        }, RequestContext(request))

