diff mbox

[Branch,~linaro-validation/lava-dashboard/trunk] Rev 343: allow filters to match multiple tests and test cases

Message ID 20120923222409.16000.11757.launchpad@ackee.canonical.com
State Accepted
Headers show

Commit Message

Michael-Doyle Hudson Sept. 23, 2012, 10:24 p.m. UTC
Merge authors:
  Michael Hudson-Doyle (mwhudson)
Related merge proposals:
  https://code.launchpad.net/~mwhudson/lava-dashboard/trf-multiple-test-cases/+merge/125385
  proposed by: Michael Hudson-Doyle (mwhudson)
  review: Approve - Andy Doan (doanac)
------------------------------------------------------------
revno: 343 [merge]
committer: Michael Hudson-Doyle <michael.hudson@linaro.org>
branch nick: trunk
timestamp: Mon 2012-09-24 10:09:37 +1200
message:
  allow filters to match multiple tests and test cases
added:
  dashboard_app/migrations/0022_auto__add_testrunfiltertest__add_testrunfiltertestcase__del_field_test.py
  dashboard_app/templates/dashboard_app/filter_form_test.html
  dashboard_app/templates/dashboard_app/filter_results_table.html
modified:
  dashboard_app/admin.py
  dashboard_app/models.py
  dashboard_app/static/css/filter-edit.css
  dashboard_app/static/js/filter-edit.js
  dashboard_app/static/js/jquery.formset.js
  dashboard_app/templates/dashboard_app/filter_form.html
  dashboard_app/templates/dashboard_app/filter_summary.html
  dashboard_app/views.py


--
lp:lava-dashboard
https://code.launchpad.net/~linaro-validation/lava-dashboard/trunk

You are subscribed to branch lp:lava-dashboard.
To unsubscribe from this branch go to https://code.launchpad.net/~linaro-validation/lava-dashboard/trunk/+edit-subscription
diff mbox

Patch

=== modified file 'dashboard_app/admin.py'
--- dashboard_app/admin.py	2012-08-17 02:05:39 +0000
+++ dashboard_app/admin.py	2012-09-12 01:45:09 +0000
@@ -209,7 +209,6 @@ 
     class TestRunFilterAttributeInline(admin.TabularInline):
         model = TestRunFilterAttribute
     inlines = [TestRunFilterAttributeInline]
-    raw_id_fields = ['test_case']
     save_as = True
 
 

=== added file 'dashboard_app/migrations/0022_auto__add_testrunfiltertest__add_testrunfiltertestcase__del_field_test.py'
--- dashboard_app/migrations/0022_auto__add_testrunfiltertest__add_testrunfiltertestcase__del_field_test.py	1970-01-01 00:00:00 +0000
+++ dashboard_app/migrations/0022_auto__add_testrunfiltertest__add_testrunfiltertestcase__del_field_test.py	2012-09-12 01:16:05 +0000
@@ -0,0 +1,309 @@ 
+# -*- 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 'TestRunFilterTest'
+        db.create_table('dashboard_app_testrunfiltertest', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('test', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['dashboard_app.Test'])),
+            ('filter', self.gf('django.db.models.fields.related.ForeignKey')(related_name='tests', to=orm['dashboard_app.TestRunFilter'])),
+            ('index', self.gf('django.db.models.fields.PositiveIntegerField')()),
+        ))
+        db.send_create_signal('dashboard_app', ['TestRunFilterTest'])
+
+        # Adding model 'TestRunFilterTestCase'
+        db.create_table('dashboard_app_testrunfiltertestcase', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('test_case', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['dashboard_app.TestCase'])),
+            ('test', self.gf('django.db.models.fields.related.ForeignKey')(related_name='cases', to=orm['dashboard_app.TestRunFilterTest'])),
+            ('index', self.gf('django.db.models.fields.PositiveIntegerField')()),
+        ))
+        db.send_create_signal('dashboard_app', ['TestRunFilterTestCase'])
+
+        # Deleting field 'TestRunFilter.test'
+        db.delete_column('dashboard_app_testrunfilter', 'test_id')
+
+        # Deleting field 'TestRunFilter.test_case'
+        db.delete_column('dashboard_app_testrunfilter', 'test_case_id')
+
+
+    def backwards(self, orm):
+        # Deleting model 'TestRunFilterTest'
+        db.delete_table('dashboard_app_testrunfiltertest')
+
+        # Deleting model 'TestRunFilterTestCase'
+        db.delete_table('dashboard_app_testrunfiltertestcase')
+
+        # Adding field 'TestRunFilter.test'
+        db.add_column('dashboard_app_testrunfilter', 'test',
+                      self.gf('django.db.models.fields.related.ForeignKey')(to=orm['dashboard_app.Test'], null=True, blank=True),
+                      keep_default=False)
+
+        # Adding field 'TestRunFilter.test_case'
+        db.add_column('dashboard_app_testrunfilter', 'test_case',
+                      self.gf('django.db.models.fields.related.ForeignKey')(to=orm['dashboard_app.TestCase'], null=True, blank=True),
+                      keep_default=False)
+
+
+    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.SlugField', [], {'unique': 'True', 'max_length': '1024'}),
+            'uploaded_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'})
+        },
+        '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.launchpadbug': {
+            'Meta': {'object_name': 'LaunchpadBug'},
+            'bug_id': ('django.db.models.fields.PositiveIntegerField', [], {'unique': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'test_runs': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'launchpad_bugs'", 'symmetrical': 'False', 'to': "orm['dashboard_app.TestRun']"})
+        },
+        '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']"})
+        },
+        'dashboard_app.testrunfilter': {
+            'Meta': {'unique_together': "(('owner', 'name'),)", 'object_name': 'TestRunFilter'},
+            'build_number_attribute': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'null': 'True', 'blank': 'True'}),
+            '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.SlugField', [], {'max_length': '1024'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+            'public': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
+        },
+        'dashboard_app.testrunfilterattribute': {
+            'Meta': {'object_name': 'TestRunFilterAttribute'},
+            'filter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attributes'", 'to': "orm['dashboard_app.TestRunFilter']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '1024'}),
+            'value': ('django.db.models.fields.CharField', [], {'max_length': '1024'})
+        },
+        'dashboard_app.testrunfiltersubscription': {
+            'Meta': {'unique_together': "(('user', 'filter'),)", 'object_name': 'TestRunFilterSubscription'},
+            'filter': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['dashboard_app.TestRunFilter']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'level': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'dashboard_app.testrunfiltertest': {
+            'Meta': {'object_name': 'TestRunFilterTest'},
+            'filter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'tests'", 'to': "orm['dashboard_app.TestRunFilter']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'test': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['dashboard_app.Test']"})
+        },
+        'dashboard_app.testrunfiltertestcase': {
+            'Meta': {'object_name': 'TestRunFilterTestCase'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'index': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'test': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'cases'", 'to': "orm['dashboard_app.TestRunFilterTest']"}),
+            'test_case': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['dashboard_app.TestCase']"})
+        },
+        '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-09-06 21:24:39 +0000
+++ dashboard_app/models.py	2012-09-19 03:06:08 +0000
@@ -1579,16 +1579,16 @@ 
     pass_count = None # Only filled out for filters that dont specify a test
     result_code = None # Ditto
 
-    def _format_test_result(self, test_case, result):
-        if test_case.units:
-            if self.filter.test_case.units:
-                return '%s%s' % (result.measurement, result.units)
-            else:
-                return result.RESULT_MAP[result.result]
+    def _format_test_result(self, result):
+        prefix = result.test_case.test.test_id + ':' + result.test_case.test_case_id + ' '
+        if result.test_case.units:
+            return prefix + '%s%s' % (result.measurement, result.units)
+        else:
+            return prefix + result.RESULT_MAP[result.result]
 
-    def _format_test_run(self, test, tr):
+    def _format_test_run(self, tr):
         return "%s %s pass / %s total" % (
-            test.test_id,
+            tr.test.test_id,
             tr.denormalization.count_pass,
             tr.denormalization.count_all())
 
@@ -1597,21 +1597,20 @@ 
 
     def format_for_mail(self):
         r = [' ~%s/%s ' % (self.filter.owner.username, self.filter.name)]
-        if self.filter.test_case:
-            r.append("%s:%s" % (
-                self.filter.test.test_id,
-                self.filter.test_case.test_case_id,
-                ))
-            r.append(' ' + ', '.join(
-                self._format_test_result(self.filter.test_case, r)
-                for r in self.specific_results))
-        elif self.filter.test:
-            r.append(self.filter.test.test_id)
-            r.append(' ' + ', '.join(
-                self._format_test_run(self.filter.test, tr)
-                for tr in self.test_runs))
-        else:
+        if not self.filter_data['tests']:
             r.append(self._format_many_test_runs())
+        else:
+            for test in self.filter_data['tests']:
+                if not test.all_case_ids():
+                    for tr in self.test_runs:
+                        if tr.test == test.test:
+                            r.append('\n    ')
+                            r.append(self._format_test_run(tr))
+                for case_id in test.all_case_ids():
+                    for result in self.specific_results:
+                        if result.test_case.id == case_id:
+                            r.append('\n    ')
+                            r.append(self._format_test_result(result))
         r.append('\n')
         return ''.join(r)
 
@@ -1634,10 +1633,6 @@ 
         else:
             self.key = 'bundle__uploaded_on'
             self.key_name = 'Uploaded On'
-        if filter_data['test_case']:
-            self.has_specific_results = True
-        else:
-            self.has_specific_results = False
 
     def _makeMatches(self, data):
         test_run_ids = set()
@@ -1645,17 +1640,20 @@ 
             test_run_ids.update(datum['id__arrayagg'])
         r = []
         trs = TestRun.objects.filter(id__in=test_run_ids).select_related(
-            'denormalization', 'bundle', 'bundle__bundle_stream')
+            'denormalization', 'bundle', 'bundle__bundle_stream', 'test')
         trs_by_id = {}
         for tr in trs:
             trs_by_id[tr.id] = tr
-        if self.has_specific_results:
+        case_ids = set()
+        for t in self.filter_data['tests']:
+            case_ids.update(t.all_case_ids())
+        if case_ids:
             result_ids_by_tr_id = {}
             results_by_tr_id = {}
-            values = TestRun.objects.filter(
-                id__in=test_run_ids,
-                test_results__test_case=self.filter_data['test_case']).values_list(
-                'id', 'test_results')
+            values = TestResult.objects.filter(
+                test_case__id__in=case_ids,
+                test_run__id__in=test_run_ids).values_list(
+                'test_run__id', 'id')
             result_ids = set()
             for v in values:
                 result_ids_by_tr_id.setdefault(v[0], []).append(v[1])
@@ -1673,16 +1671,16 @@ 
                     rs.append(results_by_id[result_id])
         for datum in data:
             trs = []
-            for id in datum['id__arrayagg']:
+            for id in set(datum['id__arrayagg']):
                 trs.append(trs_by_id[id])
             match = FilterMatch()
             match.test_runs = trs
             match.filter_data = self.filter_data
             match.tag = datum[self.key]
-            if self.has_specific_results:
+            if case_ids:
                 match.specific_results = []
-                for id in datum['id__arrayagg']:
-                    match.specific_results.extend(results_by_tr_id[id])
+                for id in set(datum['id__arrayagg']):
+                    match.specific_results.extend(results_by_tr_id.get(id, []))
             else:
                 match.pass_count = sum(tr.denormalization.count_pass for tr in trs)
                 match.result_count = sum(tr.denormalization.count_all() for tr in trs)
@@ -1718,6 +1716,34 @@ 
         return '%s = %s' % (self.name, self.value)
 
 
+class TestRunFilterTest(models.Model):
+
+    test = models.ForeignKey(Test, related_name="+")
+    filter = models.ForeignKey("TestRunFilter", related_name="tests")
+    index = models.PositiveIntegerField(
+        help_text = _(u"The index of this test in the filter"))
+
+    def all_case_ids(self):
+        return self.cases.all().order_by('index').values_list('test_case__id', flat=True)
+
+    def all_case_names(self):
+        return self.cases.all().order_by('index').values_list('test_case__test_case_id', flat=True)
+
+    def __unicode__(self):
+        return unicode(self.test)
+
+
+class TestRunFilterTestCase(models.Model):
+
+    test_case = models.ForeignKey(TestCase, related_name="+")
+    test = models.ForeignKey(TestRunFilterTest, related_name="cases")
+    index = models.PositiveIntegerField(
+        help_text = _(u"The index of this case in the test"))
+
+    def __unicode__(self):
+        return unicode(self.test_case)
+
+
 class SQLArrayAgg(SQLAggregate):
     sql_function = 'array_agg'
 
@@ -1749,13 +1775,6 @@ 
     bundle_streams = models.ManyToManyField(BundleStream)
     bundle_streams.help_text = 'A filter only matches tests within the given <b>bundle streams</b>.'
 
-    test = models.ForeignKey(
-        Test, blank=True, null=True,
-        help_text=("A filter can optionally be restricted to a particular "
-                   "<b>test</b>, or even a <b>test case</b> within a test."))
-
-    test_case = models.ForeignKey(TestCase, blank=True, null=True)
-
     public = models.BooleanField(
         default=False, help_text="Whether other users can see this filter.")
 
@@ -1768,27 +1787,12 @@ 
         return {
             'bundle_streams': self.bundle_streams.all(),
             'attributes': self.attributes.all().values_list('name', 'value'),
-            'test': self.test,
-            'test_case': self.test_case,
+            'tests': self.tests.all().prefetch_related('cases'),
             'build_number_attribute': self.build_number_attribute,
             }
 
     def __unicode__(self):
-        test = self.test
-        if not test:
-            test = "<any>"
-        test_case = self.test_case
-        if not test_case:
-            test_case = "<any>"
-        attrs = []
-        for attr in self.attributes.all():
-            attrs.append(unicode(attr))
-        attrs = ', '.join(attrs)
-        if attrs:
-            attrs = ' ' + attrs + '; '
-        return "<TestRunFilter ~%s/%s %d streams;%s %s:%s>" % (
-            self.owner.username, self.name, self.bundle_streams.count(), attrs, test, test_case)
-
+        return "<TestRunFilter ~%s/%s>" % (self.owner.username, self.name)
 
     # given filter:
     # select from testrun
@@ -1797,9 +1801,9 @@ 
     #    and testrun has attribute with key = key2 and value = value2
     #    and               ...
     #    and testrun has attribute with key = keyN and value = valueN
-    #    and testrun has filter.test/testcase requested
+    #    and testrun has any of the tests/testcases requested
 
-    def get_test_runs_impl(self, user, bundle_streams, attributes):
+    def get_test_runs_impl(self, user, bundle_streams, attributes, tests):
         accessible_bundle_streams = BundleStream.objects.accessible_by_principal(
             user)
         bs_ids = [bs.id for bs in set(accessible_bundle_streams) & set(bundle_streams)]
@@ -1815,12 +1819,21 @@ 
                     name=name, value=value, content_type_id=content_type_id
                     ).values('object_id')))
 
-        if self.test_case:
-            conditions.append(models.Q(
-                test_results__test_case=self.test_case,
-                test=self.test_case.test))
-        elif self.test:
-            conditions.append(models.Q(test=self.test))
+        test_condition = None
+        for test in tests:
+            cases = list(test.all_case_ids())
+            if cases:
+                q = models.Q(
+                    test__id=test.test.id,
+                    test_results__test_case__id__in=cases)
+            else:
+                q = models.Q(test__id=test.test.id)
+            if test_condition:
+                test_condition = test_condition | q
+            else:
+                test_condition = q
+        if test_condition:
+            conditions.append(test_condition)
 
         testruns = TestRun.objects.filter(*conditions)
 
@@ -1840,8 +1853,7 @@ 
         filter_data = {
             'bundle_streams': bundle_streams,
             'attributes': attributes,
-            'test': self.test,
-            'test_case': self.test_case,
+            'tests': tests,
             'build_number_attribute': self.build_number_attribute,
             }
 
@@ -1858,15 +1870,8 @@ 
 
     @classmethod
     def matches_against_bundle(self, bundle):
-        filters = bundle.bundle_stream.testrunfilter_set.all()
-        filters = filters.filter(
-            models.Q(test__isnull=True)
-            |models.Q(test__in=bundle.test_runs.all().values('test')))
-        filters = filters.filter(
-            models.Q(test_case__isnull=True)
-            |models.Q(test_case__in=TestResult.objects.filter(
-                test_run__in=bundle.test_runs.all()).values('test_case')))
-        filters = filters.extra(
+        bundle_filters = bundle.bundle_stream.testrunfilter_set.all()
+        attribute_filters = list(bundle_filters.extra(
             where=[
             """(select min((select count(*)
                               from dashboard_app_testrunfilterattribute
@@ -1878,8 +1883,23 @@ 
                                           where app_label = 'dashboard_app' and model='testrun')
                                  and object_id = dashboard_app_testrun.id)))
             from dashboard_app_testrun where dashboard_app_testrun.bundle_id = %s) = 0""" % bundle.id],
+            ))
+        no_test_filters = []#list(attribute_filters.annotate(models.Count('tests')).filter(tests__count=0))
+        no_test_case_filters = list(
+            TestRunFilter.objects.filter(
+                id__in=TestRunFilterTest.objects.filter(
+                    filter__in=attribute_filters, test__in=bundle.test_runs.all().values('test_id')).annotate(
+                    models.Count('cases')).filter(cases__count=0).values('filter__id'),
+                ))
+        tcf = TestRunFilter.objects.filter(
+            id__in=TestRunFilterTest.objects.filter(
+                filter__in=attribute_filters,
+                cases__test_case__id__in=bundle.test_runs.all().values('test_results__test_case__id')
+                ).values('filter__id')
             )
-        filters = list(filters)
+        test_case_filters = list(tcf)
+
+        filters = set(test_case_filters + no_test_case_filters + no_test_filters)
         matches = []
         bundle_with_counts = Bundle.objects.annotate(
             pass_count=models.Sum('test_runs__denormalization__count_pass'),
@@ -1888,29 +1908,26 @@ 
             fail_count=models.Sum('test_runs__denormalization__count_fail')).get(
             id=bundle.id)
         for filter in filters:
-            if filter.test:
-                match = FilterMatch()
-                match.test_runs = list(bundle.test_runs.filter(test=filter.test))
-                match.filter = filter
-                if filter.test_case:
-                    match.specific_results = list(
-                        TestResult.objects.filter(test_case=filter.test_case, test_run__bundle=bundle))
-                matches.append(match)
-            else:
-                match = FilterMatch()
-                match.filter = filter
-                match.test_runs = list(bundle.test_runs.all())
-                b = bundle_with_counts
-                match.result_count = b.unknown_count + b.skip_count + b.pass_count + b.fail_count
-                match.pass_count = bundle_with_counts.pass_count
-                matches.append(match)
+            match = FilterMatch()
+            match.filter = filter
+            match.filter_data = filter.summary_data
+            match.test_runs = list(bundle.test_runs.all())
+            match.specific_results = list(
+                TestResult.objects.filter(
+                    test_case__id__in=filter.tests.all().values('cases__test_case__id'),
+                    test_run__bundle=bundle))
+            b = bundle_with_counts
+            match.result_count = b.unknown_count + b.skip_count + b.pass_count + b.fail_count
+            match.pass_count = bundle_with_counts.pass_count
+            matches.append(match)
         return matches
 
     def get_test_runs(self, user):
         return self.get_test_runs_impl(
             user,
             self.bundle_streams.all(),
-            self.attributes.values_list('name', 'value'))
+            self.attributes.values_list('name', 'value'),
+            self.tests.all())
 
     @models.permalink
     def get_absolute_url(self):
@@ -1957,8 +1974,27 @@ 
         recipients = {}
         for sub in subscriptions:
             match = matches_by_filter_id[sub.filter.id]
-            if sub.level == cls.NOTIFICATION_FAILURE and match.pass_count == match.result_count:
-                continue
+            if sub.level == cls.NOTIFICATION_FAILURE:
+                failure_found = False
+                if not match.filter_data['tests']:
+                    failure_found = match.pass_count != match.result_count
+                else:
+                    for t in match.filter_data['tests']:
+                        if not t.all_case_ids():
+                            for tr in match.test_runs:
+                                if tr.test == t.test:
+                                    if tr.denormalization.count_pass != tr.denormalization.count_all():
+                                        failure_found = True
+                                        break
+                        if failure_found:
+                            break
+                if not failure_found:
+                    for r in match.specific_results:
+                        if r.result != TestResult.RESULT_PASS:
+                            failure_found = True
+                            break
+                if not failure_found:
+                    continue
             recipients.setdefault(sub.user, []).append(match)
         return recipients
 

=== modified file 'dashboard_app/static/css/filter-edit.css'
--- dashboard_app/static/css/filter-edit.css	2012-08-14 02:25:44 +0000
+++ dashboard_app/static/css/filter-edit.css	2012-09-13 03:16:48 +0000
@@ -8,3 +8,13 @@ 
   border: 1px solid rgb(204, 204, 204);
   border-top: none;
 }
+td.test-cell {
+  vertical-align: top;
+}
+table.test-case-formset {
+  border-collapse: collapse;
+}
+table.test-case-formset td {
+  padding-top: 0;
+  padding-bottom: 0;
+}
\ No newline at end of file

=== modified file 'dashboard_app/static/js/filter-edit.js'
--- dashboard_app/static/js/filter-edit.js	2012-09-09 23:30:23 +0000
+++ dashboard_app/static/js/filter-edit.js	2012-09-20 22:17:38 +0000
@@ -1,28 +1,35 @@ 
 $(function () {
 function updateTestCasesFromTest() {
-    var test_id=$("#id_test option:selected").html();
-    var select = $("#id_test_case");
-    select.empty();
-    select.append(Option("<any>", ""));
-    if (test_id != '&lt;any&gt;') {
-        $.ajax(
-            {
-                url: test_case_url + test_id,
-                dataType: 'json',
-                success: function (data) {
-                    $(data).each(
-                        function (index, val) {
-                            select.append(Option(val.test_case_id, val.id));
-                        });
-                    select.removeAttr("disabled");
-                }
-            });
-    } else {
-        select.attr('disabled', 'disabled');
-    }
+    var test_id=$(this).find("option:selected").html();
+    var selects = $(this).closest('tr').find('.test-case-formset select');
+    selects.each(
+        function () {
+            $(this).empty();
+        });
+    $.ajax(
+        {
+            url: test_case_url + test_id,
+            dataType: 'json',
+            success: function (data) {
+                selects.each(
+                    function () {
+                        var select = $(this);
+                        $(data).each(
+                            function (index, val) {
+                                var test_case_id = val.test_case_id;
+                                if (test_case_id.length > 50) {
+                                    test_case_id = test_case_id.substring(0, 50) + "...";
+                                }
+                                select.append(new Option(test_case_id, val.id));
+                            });
+                        select.removeAttr("disabled");
+                    });
+            }
+        });
 };
 
-$("#id_test").change(updateTestCasesFromTest);
+$("#id_tests_empty_form .test-case-formset-empty select").attr('disabled', 'disabled');
+$(".test-cell select").change(updateTestCasesFromTest);
 
 var nameAutocompleteConfig = {
         source: attr_name_completion_url
@@ -51,6 +58,7 @@ 
     {
         formTemplate: '#id_attributes_empty_form',
         prefix: "attributes",
+        formCssClass: "attributes-dynamic-form",
         addText: "Add a required attribute",
         added: function(row) {
             row.find(".name input").unbind();
@@ -60,4 +68,66 @@ 
         }
     });
 
+var formsetCallCount = 0;
+
+function formsetTestCase(test_row) {
+    var addText;
+    if (test_row.find(".test-case-formset select").size() < 2) {
+        addText = 'Specify test cases';
+    } else {
+        addText = 'Add another test case';
+        test_row.find('> td:last').hide();
+    }
+
+    var index = test_row.parent().children('.test-dynamic-form').index(test_row);
+
+    var fs = test_row.find(".test-case-formset > tbody > tr").formset(
+        {
+            formTemplate: test_row.find(".test-case-formset-empty"),
+            formCssClass: "test-cases-dynamic-form-" + formsetCallCount,
+            addText: addText,
+            deleteText: "Remove test case",
+            prefix: "tests-" + index,
+            added: function (row2) {
+                test_row.find('.add-row').text('Add another test case');
+                test_row.find('> td:last').hide();
+            },
+            removed: function (row2) {
+                if (test_row.find(".test-case-formset select").size() < 2) {
+                    test_row.find('.add-row').text("Specify test cases");
+                    test_row.find('> td:last').show();
+                }
+            }
+        }
+    );
+
+    test_row.data('formset', fs);
+
+    formsetCallCount += 1;
+}
+
+$("#tests-table > tbody > tr").formset(
+    {
+        formTemplate: '#id_tests_empty_form',
+        prefix: "tests",
+        formCssClass: "test-dynamic-form",
+        addText: "Add a test",
+        deleteText: "Remove test",
+        added: formsetTestCase,
+        removed: function () {
+            $("#tests-table > tbody > tr.test-dynamic-form").each(
+                function () {
+                    var index = $(this).parent().children('.test-dynamic-form').index($(this));
+                    $(this).data('formset').data('options').prefix = 'tests-' + index;
+                });
+        }
+    }
+);
+
+$("#tests-table > tbody > tr").each(
+    function () {
+        formsetTestCase($(this));
+    }
+);
+
 });
\ No newline at end of file

=== modified file 'dashboard_app/static/js/jquery.formset.js'
--- dashboard_app/static/js/jquery.formset.js	2012-09-07 00:53:52 +0000
+++ dashboard_app/static/js/jquery.formset.js	2012-09-19 04:02:42 +0000
@@ -115,10 +115,12 @@ 
             }
             if (hasChildElements(row)) {
                 row.addClass(options.formCssClass);
-                if (row.is(':visible')) {
+// XXX mwhudson 2012-09-13: not sure what this check is for, doesn't
+// work well when whole form is hidden though...
+//                if (row.is(':visible')) {
                     insertDeleteLink(row);
                     applyExtraClasses(row, i);
-                }
+//                }
             }
         });
 
@@ -152,6 +154,7 @@ 
             }
             // FIXME: Perhaps using $.data would be a better idea?
             options.formTemplate = template;
+            $$.data('options', options);
 
             if ($$.attr('tagName') == 'TR') {
                 // If forms are laid out as table rows, insert the

=== modified file 'dashboard_app/templates/dashboard_app/filter_form.html'
--- dashboard_app/templates/dashboard_app/filter_form.html	2012-09-09 23:30:23 +0000
+++ dashboard_app/templates/dashboard_app/filter_form.html	2012-09-19 22:56:03 +0000
@@ -85,17 +85,40 @@ 
         </span>
       </dd>
       <dt>
-        Test and test case:
+        Tests and test cases:
       </dt>
       <dd>
-        {{ form.test.errors }}
-        {{ form.test_case.errors }}
-        {{ form.test }}
-        {{ form.test_case }}
-        <br />
-        <span class="helptext">
-          A filter can optionally be restricted to a particular <b>test</b>, or even a <b>test case</b>
-          within a test.
-        </span>
+        {% with form.tests_formset as formset %}
+        <table id="tests-table">
+          <thead>
+            <tr>
+              <th>
+                Test
+              </th>
+              <th>
+                Test Cases
+              </th>
+            </tr>
+          </thead>
+          {{ formset.management_form }}
+          <tbody>
+            {% for form in formset %}
+            <tr>
+              {% include "dashboard_app/filter_form_test.html" %}
+            </tr>
+            {% empty %}
+            <tr>
+            </tr>
+            {% endfor %}
+          </tbody>
+          <tfoot>
+            {% with formset.empty_form as form %}
+            <tr style="display:none" id="id_tests_empty_form">
+              {% include "dashboard_app/filter_form_test.html" %}
+            </tr>
+            {% endwith %}
+          </tfoot>
+        </table>
+        {% endwith %}
       </dd>
     </dl>

=== added file 'dashboard_app/templates/dashboard_app/filter_form_test.html'
--- dashboard_app/templates/dashboard_app/filter_form_test.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_form_test.html	2012-09-19 22:56:03 +0000
@@ -0,0 +1,37 @@ 
+              <td class="test-cell">
+                {{ form.test.errors }}
+                {{ form.test }}
+              </td>
+              <td>
+                <table class="test-case-formset">
+                  {{ form.test_case_formset.management_form }}
+                  <tbody>
+                    {% for form in form.test_case_formset %}
+                    <tr>
+                      <td>
+                        {{ form.test_case.errors }}
+                        {{ form.test_case }}
+                      </td>
+                      <td>
+                      </td>
+                    </tr>
+                    {% empty %}
+                    <tr>
+                    </tr>
+                    {% endfor %}
+                  </tbody>
+                  <tfoot>
+                    {% with form.test_case_formset as formset %}
+                    <tr style="display:none" class="test-case-formset-empty">
+                      <td>
+                        {{ formset.empty_form.test_case }}
+                      </td>
+                      <td>
+                      </td>
+                    </tr>
+                    {% endwith %}
+                  </tfoot>
+                </table>
+              </td>
+              <td>
+              </td>

=== added file 'dashboard_app/templates/dashboard_app/filter_results_table.html'
--- dashboard_app/templates/dashboard_app/filter_results_table.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_results_table.html	2012-09-14 03:14:43 +0000
@@ -0,0 +1,28 @@ 
+{% extends "ajax_table.html" %}
+
+{% block table.thead %}
+{% if table.complex_header %}
+<thead>
+  <tr>
+    {% for column in table.columns %}
+    {% if not column.column.in_group %}
+    <th {{ column.attrs.th.as_html }} rowspan="2">{{ column.header }}</th>
+    {% else %}
+    {% if column.column.first_in_group %}
+    <th class="ui-state-default" colspan="{{ column.column.group_length }}">{{ column.column.group_name }}</th>
+    {% endif %}
+    {% endif %}
+    {% endfor %}
+  </tr>
+  <tr>
+    {% for column in table.columns %}
+    {% if column.column.in_group %}
+    <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
+    {% endif %}
+    {% endfor %}
+  </tr>
+</thead>
+{% else %}
+{{ block.super }}
+{% endif %}
+{% endblock table.thead %}

=== modified file 'dashboard_app/templates/dashboard_app/filter_summary.html'
--- dashboard_app/templates/dashboard_app/filter_summary.html	2012-09-05 02:59:43 +0000
+++ dashboard_app/templates/dashboard_app/filter_summary.html	2012-09-13 04:28:04 +0000
@@ -33,16 +33,27 @@ 
 {% endif %}
   <tr>
     <th>
-      Test case
+      Test cases
     </th>
     <td>
-    {% if summary_data.test_case %}
-        {{ summary_data.test }}:{{ summary_data.test_case }}
-    {% elif summary_data.test %}
-        {{ summary_data.test }}:&lt;any&gt;
-    {% else %}
-        &lt;any&gt;:&lt;any&gt;
-    {% endif %}
+      <table>
+        <tbody>
+          {% for test in summary_data.tests %}
+          <tr>
+            <td>
+              {{ test.test }}
+            </td>
+            <td>
+              {% for test_case in test.all_case_names %}
+              {{ test_case }}
+              {% empty %}
+              <i>any</i>
+              {% endfor %}
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
     </td>
   </tr>
 </table>

=== modified file 'dashboard_app/views.py'
--- dashboard_app/views.py	2012-09-09 23:13:10 +0000
+++ dashboard_app/views.py	2012-09-19 22:56:03 +0000
@@ -34,7 +34,8 @@ 
 from django.db.models.manager import Manager
 from django.db.models.query import QuerySet
 from django import forms
-from django.forms.formsets import formset_factory
+from django.forms.formsets import BaseFormSet, formset_factory
+from django.forms.widgets import Select
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.shortcuts import render_to_response, redirect, get_object_or_404
 from django.template import RequestContext, loader
@@ -472,13 +473,24 @@ 
     ''')
 
     test = TemplateColumn('''
-    {% if record.test_case %}
-        {{ record.test }}:{{ record.test_case }}
-    {% elif record.test %}
-        {{ record.test }}:&lt;any&gt;
-    {% else %}
-        &lt;any&gt;:&lt;any&gt;
-    {% endif %}
+      <table style="border-collapse: collapse">
+        <tbody>
+          {% for test in record.tests.all %}
+          <tr>
+            <td>
+              {{ test.test }}
+            </td>
+            <td>
+              {% for test_case in test.all_case_names %}
+              {{ test_case }}
+              {% empty %}
+              <i>any</i>
+              {% endfor %}
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
     ''')
 
     subscription = Column()
@@ -532,10 +544,27 @@ 
     )
 
 
+class TestRunColumn(Column):
+    def render(self, record):
+        # This column is only rendered if we don't really expect
+        # record.test_runs to be very long...
+        links = []
+        trs = [tr for tr in record.test_runs if tr.test.test_id == self.verbose_name]
+        for tr in trs:
+            text = '%s / %s' % (tr.denormalization.count_pass, tr.denormalization.count_all())
+            links.append('<a href="%s">%s</a>' % (tr.get_absolute_url(), text))
+        return mark_safe('&nbsp;'.join(links))
+
+
 class SpecificCaseColumn(Column):
-    def render(self, value, record):
+    def __init__(self, verbose_name, test_case_id):
+        super(SpecificCaseColumn, self).__init__(verbose_name)
+        self.test_case_id = test_case_id
+    def render(self, record):
         r = []
-        for result in value:
+        for result in record.specific_results:
+            if result.test_case_id != self.test_case_id:
+                continue
             if result.result == result.RESULT_PASS and result.units:
                 s = '%s %s' % (result.measurement, result.units)
             else:
@@ -551,28 +580,38 @@ 
 
 class FilterTable(DataTablesTable):
     def __init__(self, *args, **kwargs):
+        kwargs['template'] = 'dashboard_app/filter_results_table.html'
         super(FilterTable, self).__init__(*args, **kwargs)
         match_maker = self.data.queryset
         self.base_columns['tag'].verbose_name = match_maker.key_name
         bundle_stream_col = self.base_columns.pop('bundle_stream')
         bundle_col = self.base_columns.pop('bundle')
         tag_col = self.base_columns.pop('tag')
-        test_run_col = self.base_columns.pop('test_run')
-        specific_results_col = self.base_columns.pop('specific_results')
-        if match_maker.filter_data['test_case']:
-            del self.base_columns['passes']
-            del self.base_columns['total']
-            col_name = '%s:%s' % (
-                match_maker.filter_data['test'].test_id,
-                match_maker.filter_data['test_case'].test_case_id
-                )
-            specific_results_col.verbose_name = mark_safe(col_name)
-            self.base_columns.insert(0, 'specific_results', specific_results_col)
-        elif match_maker.filter_data['test']:
-            del self.base_columns['passes']
-            del self.base_columns['total']
-            test_run_col.verbose_name = mark_safe(match_maker.filter_data['test'].test_id)
-            self.base_columns.insert(0, 'test_run', test_run_col)
+        self.complex_header = False
+        if match_maker.filter_data['tests']:
+            del self.base_columns['passes']
+            del self.base_columns['total']
+            for i, t in enumerate(reversed(match_maker.filter_data['tests'])):
+                if len(t.all_case_names()) == 0:
+                    col = TestRunColumn(mark_safe(t.test.test_id))
+                    self.base_columns.insert(0, 'test_run_%s' % i, col)
+                elif len(t.all_case_names()) == 1:
+                    n = t.test.test_id + ':' + t.all_case_names()[0]
+                    col = SpecificCaseColumn(mark_safe(n), t.all_case_ids()[0])
+                    self.base_columns.insert(0, 'test_run_%s_case' % i, col)
+                else:
+                    col0 = SpecificCaseColumn(mark_safe(t.all_case_names()[0]), t.all_case_ids()[0])
+                    col0.in_group = True
+                    col0.first_in_group = True
+                    col0.group_length = len(t.all_case_names())
+                    col0.group_name = mark_safe(t.test.test_id)
+                    self.complex_header = True
+                    self.base_columns.insert(0, 'test_run_%s_case_%s' % (i, 0), col0)
+                    for j, n in enumerate(t.all_case_names()[1:], 1):
+                        col = SpecificCaseColumn(mark_safe(n), t.all_case_ids()[j])
+                        col.in_group = True
+                        col.first_in_group = False
+                        self.base_columns.insert(j, 'test_run_%s_case_%s' % (i, j), col)
         else:
             self.base_columns.insert(0, 'bundle', bundle_col)
         if len(match_maker.filter_data['bundle_streams']) > 1:
@@ -599,30 +638,9 @@ 
         return mark_safe('<br />'.join(links))
     bundle = Column(mark_safe("Bundle(s)"))
 
-    def render_test_run(self, record):
-        # This column is only rendered if we don't really expect
-        # record.test_runs to be very long...
-        links = []
-        for tr in record.test_runs:
-            text = '%s / %s' % (tr.denormalization.count_pass, tr.denormalization.count_all())
-            links.append('<a href="%s">%s</a>' % (tr.get_absolute_url(), text))
-        return mark_safe('&nbsp;'.join(links))
-    test_run = Column("Results")
-
     passes = Column(accessor='pass_count')
     total = Column(accessor='result_count')
 
-    def render_specific_results(self, value, record):
-        r = []
-        for result in value:
-            if result.result == result.RESULT_PASS and result.units:
-                s = '%s %s' % (result.measurement, result.units)
-            else:
-                s = result.RESULT_MAP[result.result]
-            r.append('<a href="' + result.get_absolute_url() + '">'+s+'</a>')
-        return mark_safe(', '.join(r))
-    specific_results = Column()
-
     def get_queryset(self, user, filter):
         return filter.get_test_runs(user)
 
@@ -751,6 +769,97 @@ 
 
 AttributesFormSet = formset_factory(AttributesForm, extra=0)
 
+
+
+class TruncatingSelect(Select):
+
+    def render_option(self, selected_choices, option_value, option_label):
+        if len(option_label) > 50:
+            option_label = option_label[:50] + '...'
+        return super(TruncatingSelect, self).render_option(
+            selected_choices, option_value, option_label)
+
+
+class TRFTestCaseForm(forms.Form):
+
+    test_case = forms.ModelChoiceField(
+        queryset=TestCase.objects.none(), widget=TruncatingSelect, empty_label=None)
+
+
+class BaseTRFTestCaseFormSet(BaseFormSet):
+
+    def __init__(self, *args, **kw):
+        self._queryset = kw.pop('queryset')
+        super(BaseTRFTestCaseFormSet, self).__init__(*args, **kw)
+
+    def add_fields(self, form, index):
+        super(BaseTRFTestCaseFormSet, self).add_fields(form, index)
+        if self._queryset is not None:
+            form.fields['test_case'].queryset = self._queryset
+
+
+TRFTestCaseFormSet = formset_factory(
+    TRFTestCaseForm, extra=0, formset=BaseTRFTestCaseFormSet)
+
+
+class TRFTestForm(forms.Form):
+
+    def __init__(self, *args, **kw):
+        super(TRFTestForm, self).__init__(*args, **kw)
+        kw['initial'] = kw.get('initial', {}).get('test_cases', None)
+        kw.pop('empty_permitted', None)
+        kw['queryset'] = None
+        v = self['test'].value()
+        if v:
+            test = self.fields['test'].to_python(v)
+            queryset = TestCase.objects.filter(test=test).order_by('test_case_id')
+            kw['queryset'] = queryset
+        self.test_case_formset = TRFTestCaseFormSet(*args, **kw)
+
+    def is_valid(self):
+        return super(TRFTestForm, self).is_valid() and \
+               self.test_case_formset.is_valid()
+
+    def full_clean(self):
+        super(TRFTestForm, self).full_clean()
+        self.test_case_formset.full_clean()
+
+    test = forms.ModelChoiceField(
+        queryset=Test.objects.order_by('test_id'), required=True)
+
+
+class BaseTRFTestsFormSet(BaseFormSet):
+
+    def is_valid(self):
+        if not super(BaseTRFTestsFormSet, self).is_valid():
+            return False
+        for form in self.forms:
+            if not form.is_valid():
+                return False
+        return True
+
+
+TRFTestsFormSet = formset_factory(
+    TRFTestForm, extra=0, formset=BaseTRFTestsFormSet)
+
+
+class FakeTRFTest(object):
+    def __init__(self, form):
+        self.test = form.cleaned_data['test']
+        self.test_id = self.test.id
+        self._case_ids = []
+        self._case_names = []
+        for tc_form in form.test_case_formset:
+            self._case_ids.append(tc_form.cleaned_data['test_case'].id)
+            self._case_names.append(tc_form.cleaned_data['test_case'].test_case_id)
+
+    def all_case_ids(self):
+        return self._case_ids
+
+    def all_case_names(self):
+        return self._case_names
+
+
 class TestRunFilterForm(forms.ModelForm):
     class Meta:
         model = TestRunFilter
@@ -766,12 +875,6 @@ 
             Context({'STATIC_URL': settings.STATIC_URL})
             )) + super_media
 
-    test = forms.ModelChoiceField(
-        queryset=Test.objects.order_by('test_id'), empty_label="<any>", required=False)
-
-    test_case = forms.ModelChoiceField(
-        queryset=TestCase.objects.none(), empty_label="<any>", required=False)
-
     def validate_name(self, value):
         self.instance.name = value
         try:
@@ -789,23 +892,42 @@ 
             instance.attributes.all().delete()
             for a in self.attributes_formset.cleaned_data:
                 instance.attributes.create(name=a['name'], value=a['value'])
+            instance.tests.all().delete()
+            for i, test_form in enumerate(self.tests_formset.forms):
+                trf_test = instance.tests.create(
+                    test=test_form.cleaned_data['test'], index=i)
+                for j, test_case_form in enumerate(test_form.test_case_formset.forms):
+                    trf_test.cases.create(
+                        test_case=test_case_form.cleaned_data['test_case'], index=j)
         return instance
 
     def is_valid(self):
         return super(TestRunFilterForm, self).is_valid() and \
-               self.attributes_formset.is_valid()
+               self.attributes_formset.is_valid() and \
+               self.tests_formset.is_valid()
+
+    def full_clean(self):
+        super(TestRunFilterForm, self).full_clean()
+        self.attributes_formset.full_clean()
+        self.tests_formset.full_clean()
 
     @property
     def summary_data(self):
         data = self.cleaned_data.copy()
+        tests = []
+        for form in self.tests_formset.forms:
+            tests.append(FakeTRFTest(form))
         data['attributes'] = [
             (d['name'], d['value']) for d in self.attributes_formset.cleaned_data]
+        data['tests'] = tests
         return data
 
     def __init__(self, user, *args, **kwargs):
         super(TestRunFilterForm, self).__init__(*args, **kwargs)
         self.instance.owner = user
         kwargs.pop('instance', None)
+
+        attr_set_args = kwargs.copy()
         if self.instance.pk:
             initial = []
             for attr in self.instance.attributes.all():
@@ -813,24 +935,34 @@ 
                     'name': attr.name,
                     'value': attr.value,
                     })
-            kwargs['initial'] = initial
-        kwargs['prefix'] = 'attributes'
-        self.attributes_formset = AttributesFormSet(*args, **kwargs)
+            attr_set_args['initial'] = initial
+        attr_set_args['prefix'] = 'attributes'
+        self.attributes_formset = AttributesFormSet(*args, **attr_set_args)
+
+        tests_set_args = kwargs.copy()
+        if self.instance.pk:
+            initial = []
+            for test in self.instance.tests.all().order_by('index').prefetch_related('cases'):
+                initial.append({
+                    'test': test.test,
+                    'test_cases': [{'test_case': unicode(tc.test_case.id)} for tc in test.cases.all().order_by('index')],
+                    })
+            tests_set_args['initial'] = initial
+        tests_set_args['prefix'] = 'tests'
+        self.tests_formset = TRFTestsFormSet(*args, **tests_set_args)
+
         self.fields['bundle_streams'].queryset = \
             BundleStream.objects.accessible_by_principal(user).order_by('pathname')
         self.fields['name'].validators.append(self.validate_name)
-        test = self['test'].value()
-        if test:
-            if not isinstance(test, int):
-                test = int(repr(test)[2:-1])
-            test = Test.objects.get(pk=test)
-            self.fields['test_case'].queryset = TestCase.objects.filter(test=test).order_by('test_case_id')
 
     def get_test_runs(self, user):
         assert self.is_valid(), self.errors
         filter = self.save(commit=False)
+        tests = []
+        for form in self.tests_formset.forms:
+            tests.append(FakeTRFTest(form))
         return filter.get_test_runs_impl(
-            user, self.cleaned_data['bundle_streams'], self.summary_data['attributes'])
+            user, self.cleaned_data['bundle_streams'], self.summary_data['attributes'], tests)
 
 
 def filter_form(request, bread_crumb_trail, instance=None):