diff mbox

[Branch,~linaro-validation/lava-dashboard/trunk] Rev 337: add the (beta) concept of a test run filter and subscription

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

Commit Message

Michael-Doyle Hudson Sept. 3, 2012, 12:17 a.m. UTC
Merge authors:
  Michael Hudson-Doyle (mwhudson)
  Spring Zhang (qzhang)
Related merge proposals:
  https://code.launchpad.net/~mwhudson/lava-dashboard/test-run-filter-subscription/+merge/121105
  proposed by: Michael Hudson-Doyle (mwhudson)
  review: Approve - Andy Doan (doanac)
  https://code.launchpad.net/~mwhudson/lava-dashboard/test-run-filter-public/+merge/120271
  proposed by: Michael Hudson-Doyle (mwhudson)
  https://code.launchpad.net/~qzhang/lava-dashboard/user-notification/+merge/113167
  proposed by: Spring Zhang (qzhang)
  review: Needs Fixing - Michael Hudson-Doyle (mwhudson)
  review: Resubmit - Spring Zhang (qzhang)
------------------------------------------------------------
revno: 337 [merge]
committer: Michael Hudson-Doyle <michael.hudson@linaro.org>
branch nick: trunk
timestamp: Mon 2012-09-03 12:16:21 +1200
message:
  add the (beta) concept of a test run filter and subscription
added:
  dashboard_app/migrations/0017_auto__add_testrunfilterattribute__add_testrunfilter__add_unique_testru.py
  dashboard_app/migrations/0018_auto__add_field_testrunfilter_public.py
  dashboard_app/migrations/0019_auto__add_testrunfiltersubscription__add_unique_testrunfiltersubscript.py
  dashboard_app/static/css/filter-edit.css
  dashboard_app/static/js/filter-edit.js
  dashboard_app/templates/dashboard_app/filter_add.html
  dashboard_app/templates/dashboard_app/filter_delete.html
  dashboard_app/templates/dashboard_app/filter_detail.html
  dashboard_app/templates/dashboard_app/filter_form.html
  dashboard_app/templates/dashboard_app/filter_preview.html
  dashboard_app/templates/dashboard_app/filter_subscribe.html
  dashboard_app/templates/dashboard_app/filter_subscription_mail.txt
  dashboard_app/templates/dashboard_app/filter_summary.html
  dashboard_app/templates/dashboard_app/filters_list.html
modified:
  dashboard_app/admin.py
  dashboard_app/extension.py
  dashboard_app/models.py
  dashboard_app/urls.py
  dashboard_app/views.py
  doc/changes.rst


--
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-07-18 21:43:42 +0000
+++ dashboard_app/admin.py	2012-08-17 02:05:39 +0000
@@ -44,6 +44,9 @@ 
     TestCase,
     TestResult,
     TestRun,
+    TestRunFilter,
+    TestRunFilterAttribute,
+    TestRunFilterSubscription,
     TestingEffort,
 )
 
@@ -196,9 +199,20 @@ 
     filter_horizontal = ['images']
     save_as = True
 
+
 class LaunchpadBugAdmin(admin.ModelAdmin):
     raw_id_fields = ['test_runs']
 
+
+class TestRunFilterAdmin(admin.ModelAdmin):
+    filter_horizontal = ['bundle_streams']
+    class TestRunFilterAttributeInline(admin.TabularInline):
+        model = TestRunFilterAttribute
+    inlines = [TestRunFilterAttributeInline]
+    raw_id_fields = ['test_case']
+    save_as = True
+
+
 admin.site.register(Attachment)
 admin.site.register(Bundle, BundleAdmin)
 admin.site.register(BundleDeserializationError, BundleDeserializationErrorAdmin)
@@ -213,5 +227,7 @@ 
 admin.site.register(TestCase, TestCaseAdmin)
 admin.site.register(TestResult, TestResultAdmin)
 admin.site.register(TestRun, TestRunAdmin)
+admin.site.register(TestRunFilter, TestRunFilterAdmin)
+admin.site.register(TestRunFilterSubscription)
 admin.site.register(Tag)
 admin.site.register(TestingEffort, TestingEffortAdmin)

=== modified file 'dashboard_app/extension.py'
--- dashboard_app/extension.py	2012-07-13 01:22:03 +0000
+++ dashboard_app/extension.py	2012-09-03 00:09:09 +0000
@@ -46,6 +46,7 @@ 
             Menu("Data Views", reverse("dashboard_app.views.data_view_list")),
             Menu("Reports", reverse("dashboard_app.views.report_list")),
             Menu("Image Reports", reverse("dashboard_app.views.image_report_list")),
+            Menu("[BETA] Filters", reverse("dashboard_app.views.filters_list")),
             ]
         return menu
 

=== added file 'dashboard_app/migrations/0017_auto__add_testrunfilterattribute__add_testrunfilter__add_unique_testru.py'
--- dashboard_app/migrations/0017_auto__add_testrunfilterattribute__add_testrunfilter__add_unique_testru.py	1970-01-01 00:00:00 +0000
+++ dashboard_app/migrations/0017_auto__add_testrunfilterattribute__add_testrunfilter__add_unique_testru.py	2012-08-13 01:30:49 +0000
@@ -0,0 +1,290 @@ 
+# -*- 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 'TestRunFilterAttribute'
+        db.create_table('dashboard_app_testrunfilterattribute', (
+            ('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)),
+            ('filter', self.gf('django.db.models.fields.related.ForeignKey')(related_name='attributes', to=orm['dashboard_app.TestRunFilter'])),
+        ))
+        db.send_create_signal('dashboard_app', ['TestRunFilterAttribute'])
+
+        # Adding model 'TestRunFilter'
+        db.create_table('dashboard_app_testrunfilter', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('owner', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+            ('name', self.gf('django.db.models.fields.SlugField')(max_length=1024)),
+            ('test', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['dashboard_app.Test'], null=True, blank=True)),
+            ('test_case', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['dashboard_app.TestCase'], null=True, blank=True)),
+        ))
+        db.send_create_signal('dashboard_app', ['TestRunFilter'])
+
+        # Adding M2M table for field bundle_streams on 'TestRunFilter'
+        db.create_table('dashboard_app_testrunfilter_bundle_streams', (
+            ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
+            ('testrunfilter', models.ForeignKey(orm['dashboard_app.testrunfilter'], null=False)),
+            ('bundlestream', models.ForeignKey(orm['dashboard_app.bundlestream'], null=False))
+        ))
+        db.create_unique('dashboard_app_testrunfilter_bundle_streams', ['testrunfilter_id', 'bundlestream_id'])
+
+        # Adding unique constraint on 'TestRunFilter', fields ['owner', 'name']
+        db.create_unique('dashboard_app_testrunfilter', ['owner_id', 'name'])
+
+
+    def backwards(self, orm):
+        # Removing unique constraint on 'TestRunFilter', fields ['owner', 'name']
+        db.delete_unique('dashboard_app_testrunfilter', ['owner_id', 'name'])
+
+        # Deleting model 'TestRunFilterAttribute'
+        db.delete_table('dashboard_app_testrunfilterattribute')
+
+        # Deleting model 'TestRunFilter'
+        db.delete_table('dashboard_app_testrunfilter')
+
+        # Removing M2M table for field bundle_streams on 'TestRunFilter'
+        db.delete_table('dashboard_app_testrunfilter_bundle_streams')
+
+
+    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'},
+            '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']"}),
+            'test': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['dashboard_app.Test']", 'null': 'True', 'blank': 'True'}),
+            'test_case': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['dashboard_app.TestCase']", 'null': 'True', 'blank': 'True'})
+        },
+        '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'})
+        },
+        '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

=== added file 'dashboard_app/migrations/0018_auto__add_field_testrunfilter_public.py'
--- dashboard_app/migrations/0018_auto__add_field_testrunfilter_public.py	1970-01-01 00:00:00 +0000
+++ dashboard_app/migrations/0018_auto__add_field_testrunfilter_public.py	2012-08-15 23:40:53 +0000
@@ -0,0 +1,257 @@ 
+# -*- 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 field 'TestRunFilter.public'
+        db.add_column('dashboard_app_testrunfilter', 'public',
+                      self.gf('django.db.models.fields.BooleanField')(default=False),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Deleting field 'TestRunFilter.public'
+        db.delete_column('dashboard_app_testrunfilter', 'public')
+
+
+    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'},
+            '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'}),
+            'test': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['dashboard_app.Test']", 'null': 'True', 'blank': 'True'}),
+            'test_case': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['dashboard_app.TestCase']", 'null': 'True', 'blank': 'True'})
+        },
+        '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'})
+        },
+        '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

=== added file 'dashboard_app/migrations/0019_auto__add_testrunfiltersubscription__add_unique_testrunfiltersubscript.py'
--- dashboard_app/migrations/0019_auto__add_testrunfiltersubscription__add_unique_testrunfiltersubscript.py	1970-01-01 00:00:00 +0000
+++ dashboard_app/migrations/0019_auto__add_testrunfiltersubscription__add_unique_testrunfiltersubscript.py	2012-08-16 00:44:01 +0000
@@ -0,0 +1,274 @@ 
+# -*- 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 'TestRunFilterSubscription'
+        db.create_table('dashboard_app_testrunfiltersubscription', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+            ('filter', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['dashboard_app.TestRunFilter'])),
+            ('level', self.gf('django.db.models.fields.IntegerField')(default=0)),
+        ))
+        db.send_create_signal('dashboard_app', ['TestRunFilterSubscription'])
+
+        # Adding unique constraint on 'TestRunFilterSubscription', fields ['user', 'filter']
+        db.create_unique('dashboard_app_testrunfiltersubscription', ['user_id', 'filter_id'])
+
+
+    def backwards(self, orm):
+        # Removing unique constraint on 'TestRunFilterSubscription', fields ['user', 'filter']
+        db.delete_unique('dashboard_app_testrunfiltersubscription', ['user_id', 'filter_id'])
+
+        # Deleting model 'TestRunFilterSubscription'
+        db.delete_table('dashboard_app_testrunfiltersubscription')
+
+
+    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'},
+            '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'}),
+            'test': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['dashboard_app.Test']", 'null': 'True', 'blank': 'True'}),
+            'test_case': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['dashboard_app.TestCase']", 'null': 'True', 'blank': 'True'})
+        },
+        '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']"})
+        },
+        '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-08-27 00:31:59 +0000
+++ dashboard_app/models.py	2012-08-31 01:24:49 +0000
@@ -34,9 +34,11 @@ 
 from django.contrib.auth.models import User
 from django.contrib.contenttypes import generic
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
+from django.contrib.sites.models import Site
+from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.core.files import locks, File
 from django.core.files.storage import FileSystemStorage
+from django.core.mail import send_mail
 from django.core.urlresolvers import reverse
 from django.db import models
 from django.db.models.fields import FieldDoesNotExist
@@ -44,6 +46,7 @@ 
 from django.dispatch import receiver
 from django.template import Template, Context
 from django.template.defaultfilters import filesizeformat
+from django.template.loader import render_to_string
 from django.utils.translation import ugettext as _
 from django.utils.translation import ungettext
 
@@ -491,7 +494,6 @@ 
             return
         try:
             self._do_deserialize(prefer_evolution)
-            bundle_was_deserialized.send(sender=self, bundle=self)
         except Exception as ex:
             import_error = BundleDeserializationError.objects.get_or_create(
                 bundle=self)[0]
@@ -505,6 +507,7 @@ 
                 pass
             self.is_deserialized = True
             self.save()
+            bundle_was_deserialized.send_robust(sender=self, bundle=self)
 
     def _do_deserialize(self, prefer_evolution):
         """
@@ -1210,7 +1213,7 @@ 
     """
 
     repository = DataViewRepository()
-    
+
     def __init__(self, name, backend_queries, arguments, documentation, summary):
         self.name = name
         self.backend_queries = backend_queries
@@ -1308,7 +1311,7 @@ 
     Data reports are small snippets of xml that define
     a limited django template.
     """
-    
+
     repository = DataReportRepository()
 
     def __init__(self, **kwargs):
@@ -1544,7 +1547,7 @@ 
     for field_name in meta.get_all_field_names():
 
         # object that represents the metadata of the field
-        try:        
+        try:
             field_meta = meta.get_field(field_name)
         except FieldDoesNotExist:
             continue
@@ -1559,3 +1562,422 @@ 
         # the 'path' attribute contains the name of the file we need
         if hasattr(field, 'path') and os.path.exists(field.path):
             field.storage.delete(field.path)
+
+
+class FilterMatch(object):
+    """A non-database object that represents the way a filter matches a test_run.
+
+    Returned by TestRunFilter.matches_against_bundle and
+    TestRunFilter.get_test_runs.
+    """
+
+    bundle = None
+    specific_results = None
+    result_count = None
+    pass_count = None
+    test_run = None
+    filter = None
+
+    def format_for_mail(self):
+        r = [' ~%s/%s ' % (self.filter.owner.username, self.filter.name)]
+        if self.filter.test_case:
+            r.extend([
+                self.filter.test.test_id,
+                ':',
+                self.filter.test_case.test_case_id,
+                ])
+            for result in self.specific_results:
+                if self.filter.test_case.units:
+                    result_desc = '%s%s' % (result.measurement, result.units)
+                else:
+                    result_desc = result.RESULT_MAP[result.result]
+                r.extend([' ', result_desc])
+        elif self.filter.test:
+            r.append('%s %s pass/%s total' % (
+                self.filter.test.test_id, self.pass_count, self.result_count))
+        else:
+            r.append('%s pass/%s total' % (self.pass_count, self.result_count))
+        r.append('\n')
+        return ''.join(r)
+
+
+class MatchMakingQuerySet(object):
+    """Wrap a QuerySet and construct FilterMatchs from what the wrapped query
+    set returns.
+
+    Just enough of the QuerySet API to work with DataTable."""
+
+    model = TestRun
+
+    def __init__(self, queryset, filter):
+        self.queryset = queryset
+        self.filter = filter
+
+    def _makeMatches(self, data):
+        raise NotImplementedError(self._makeMatches)
+
+    def _wrap(self, queryset, **kw):
+        return self.__class__(queryset, self.filter, **kw)
+
+    def order_by(self, *args):
+        return self._wrap(self.queryset.order_by(*args))
+
+    def count(self):
+        return self.queryset.count()
+
+    def __getitem__(self, item):
+        return self._wrap(self.queryset[item])
+
+    def __iter__(self):
+        data = list(self.queryset)
+        return self._makeMatches(data)
+
+
+class SpecificTestCaseMatchMakingQuerySet(MatchMakingQuerySet):
+
+    def _makeMatches(self, runs):
+        results_by_run_id = {}
+        for run in runs:
+            results_by_run_id[run.id] = []
+        results = TestResult.objects.filter(
+            test_run_id__in=results_by_run_id.keys(),
+            test_case_id=self.filter.test_case.id)
+        for result in results:
+            results_by_run_id[result.test_run_id].append(result)
+        matches = []
+        for run in runs:
+            match = FilterMatch()
+            specific_results = results_by_run_id[result.test_run_id]
+            match.specific_results = specific_results
+            match.result_count = len(specific_results)
+            match.pass_count = len([r for r in specific_results if r.result == r.RESULT_PASS])
+            match.test_run = run
+            match.bundle = run.bundle
+            match.filter = self.filter
+            matches.append(match)
+        return iter(matches)
+
+
+
+class SpecificTestMatchMakingQuerySet(MatchMakingQuerySet):
+    def _makeMatches(self, runs):
+        matches = []
+        for run in runs:
+            match = FilterMatch()
+            match.specific_results = None
+            match.result_count = run.denormalization.count_all()
+            match.pass_count = run.denormalization.count_pass
+            match.test_run = run
+            match.bundle = run.bundle
+            match.filter = self.filter
+            matches.append(match)
+        return iter(matches)
+
+
+class BundleMatchMakingQuerySet(MatchMakingQuerySet):
+
+    model = Bundle
+
+    def __init__(self, queryset, filter, mis_ordered=False):
+        super(BundleMatchMakingQuerySet, self).__init__(queryset, filter)
+        self.mis_ordered = mis_ordered
+
+    def _makeMatches(self, bundles):
+        assert not self.mis_ordered, """
+           attempt to materialize BundleMatchMakingQuerySet when ordered on
+           non-bundle field"""
+        matches = []
+        counted_bundles = Bundle.objects.filter(
+            id__in=[b.id for b in bundles]).annotate(
+            pass_count=models.Sum('test_runs__denormalization__count_pass'),
+            unknown_count=models.Sum('test_runs__denormalization__count_unknown'),
+            skip_count=models.Sum('test_runs__denormalization__count_skip'),
+            fail_count=models.Sum('test_runs__denormalization__count_fail'))
+        bundles_by_id = {}
+        for bundle in counted_bundles:
+            bundles_by_id[bundle.id] = bundle
+        for bundle in bundles:
+            match = FilterMatch()
+            match.specific_results = None
+            cb = bundles_by_id[bundle.id]
+            match.result_count = cb.unknown_count + cb.skip_count + cb.pass_count + cb.fail_count
+            match.pass_count = cb.pass_count
+            match.test_run = None
+            match.bundle = bundle
+            match.filter = self.filter
+            matches.append(match)
+        return iter(matches)
+
+    def _wrap(self, queryset, **kw):
+        if 'mis_ordered' not in kw:
+            kw['mis_ordered'] = self.mis_ordered
+        return self.__class__(queryset, self.filter, **kw)
+
+    def order_by(self, field):
+        if field.startswith('bundle__') or field.startswith('-bundle__'):
+            if field.startswith('-'):
+                prefix = '-'
+                field = field[1:]
+            else:
+                prefix = ''
+            field = field[len('bundle__'):]
+            r = super(BundleMatchMakingQuerySet, self).order_by(
+                prefix+field)
+            r.mis_ordered = False
+            return r
+        else:
+            return self._wrap(self.queryset, mis_ordered=True)
+
+
+class TestRunFilterAttribute(models.Model):
+
+    name = models.CharField(max_length=1024)
+    value = models.CharField(max_length=1024)
+
+    filter = models.ForeignKey("TestRunFilter", related_name="attributes")
+
+    def __unicode__(self):
+        return '%s = %s' % (self.name, self.value)
+
+
+class TestRunFilter(models.Model):
+
+    owner = models.ForeignKey(User)
+
+    name = models.SlugField(
+        max_length=1024,
+        help_text=("The <b>name</b> of a filter is used to refer to it in "
+                   "the web UI and in email notifications triggered by this "
+                   "filter."))
+    class Meta:
+        unique_together = (('owner', 'name'))
+
+    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.")
+
+    @property
+    def summary_data(self):
+        return {
+            'bundle_streams': self.bundle_streams.all(),
+            'attributes': self.attributes.all().values_list('name', 'value'),
+            'test': self.test,
+            'test_case': self.test_case,
+            }
+
+    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)
+
+
+    # given filter:
+    # select from testrun
+    #  where testrun.bundle in filter.bundle_streams ^ accessible_bundles 
+    #    and testrun has attribute with key = key1 and value = value1
+    #    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
+
+    def get_test_runs_impl(self, user, bundle_streams, attributes):
+        accessible_bundle_streams = BundleStream.objects.accessible_by_principal(
+            user)
+        testruns = TestRun.objects.filter(
+            models.Q(bundle__bundle_stream__in=accessible_bundle_streams),
+            models.Q(bundle__bundle_stream__in=bundle_streams),
+            )
+
+        for (name, value) in attributes:
+            testruns = TestRun.objects.filter(
+                id__in=testruns.values_list('id'),
+                attributes__name=name, attributes__value=value)
+
+        if self.test_case:
+            testruns = TestRun.objects.filter(
+                id__in=testruns.values_list('id'),
+                test_results__test_case=self.test_case,
+                test=self.test_case.test)
+            wrapper_cls = SpecificTestCaseMatchMakingQuerySet
+        elif self.test:
+            testruns = TestRun.objects.filter(
+                id__in=testruns.values_list('id'),
+                test=self.test)
+            wrapper_cls = SpecificTestMatchMakingQuerySet
+        else:
+            # if the filter doesn't specify a test, we still only return one
+            # test run per bundle.  the display code knows to do different
+            # things in this case.
+            testruns = Bundle.objects.filter(
+                test_runs__in=testruns)
+            wrapper_cls = BundleMatchMakingQuerySet
+
+        return wrapper_cls(testruns, self)
+
+    # given bundle:
+    # select from filter
+    #  where bundle.bundle_stream in filter.bundle_streams
+    #    and filter.test in (select test from bundle.test_runs)
+    #    and all the attributes on the filter are on a testrun in the bundle
+    #       = the minimum over testrun (the number of attributes on the filter that are not on the testrun) is 0
+    #    and (filter.test_case is null
+    #         or filter.test_case in select test_case from bundle.test_runs.test_results.test_cases)
+
+    @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(
+            where=[
+            """(select min((select count(*)
+                              from dashboard_app_testrunfilterattribute
+                             where filter_id = dashboard_app_testrunfilter.id
+                               and (name, value) not in (select name, value
+                                                           from dashboard_app_namedattribute
+                                  where content_type_id = (
+                                          select django_content_type.id from django_content_type
+                                          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],
+            )
+        filters = list(filters)
+        matches = []
+        for filter in filters:
+            if filter.test:
+                for test_run in bundle.test_runs.filter(test=filter.test):
+                    match = FilterMatch()
+                    match.filter = filter
+                    match.test_run = test_run
+                    if filter.test_case:
+                        match.specific_results = list(
+                            test_run.test_results.filter(test_case=filter.test_case))
+                        match.result_count = len(match.specific_results)
+                        match.pass_count = len(
+                            [r for r in match.specific_results if r.result == r.RESULT_PASS])
+                    else:
+                        match.specific_results = None
+                        match.result_count = test_run.denormalization.count_all()
+                        match.pass_count = test_run.denormalization.count_pass
+                    matches.append(match)
+            else:
+                match = FilterMatch()
+                match.filter = filter
+                match.test_run = None
+                bundle_with_counts = Bundle.objects.annotate(
+                    pass_count=models.Sum('test_runs__denormalization__count_pass'),
+                    unknown_count=models.Sum('test_runs__denormalization__count_unknown'),
+                    skip_count=models.Sum('test_runs__denormalization__count_skip'),
+                    fail_count=models.Sum('test_runs__denormalization__count_fail')).get(
+                    id=bundle.id)
+                match.specific_results = None
+                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'))
+
+    @models.permalink
+    def get_absolute_url(self):
+        return (
+            "dashboard_app.views.filter_detail",
+            [self.owner.username, self.name])
+
+
+class TestRunFilterSubscription(models.Model):
+
+    user = models.ForeignKey(User)
+
+    filter = models.ForeignKey(TestRunFilter)
+
+    class Meta:
+        unique_together = (('user', 'filter'))
+
+    NOTIFICATION_FAILURE, NOTIFICATION_ALWAYS = range(2)
+
+    NOTIFICATION_CHOICES = (
+        (NOTIFICATION_FAILURE, "Only when failed"),
+        (NOTIFICATION_ALWAYS, "Always"))
+
+    level = models.IntegerField(
+        default=NOTIFICATION_FAILURE, choices=NOTIFICATION_CHOICES,
+        help_text=("You can choose to be <b>notified by email</b>:<ul><li>whenever a test "
+                   "that matches the criteria of this filter is executed"
+                   "</li><li>only when a test that matches the criteria of this filter fails</ul>"))
+
+    @classmethod
+    def recipients_for_bundle(cls, bundle):
+        matches = TestRunFilter.matches_against_bundle(bundle)
+        matches_by_filter_id = {}
+        for match in matches:
+            matches_by_filter_id[match.filter.id] = match
+        args = [models.Q(filter_id__in=list(matches_by_filter_id))]
+        bs = bundle.bundle_stream
+        if not bs.is_public:
+            if bs.group:
+                args.append(models.Q(user__in=bs.group.user_set.all()))
+            else:
+                args.append(models.Q(user=bs.user))
+        subscriptions = TestRunFilterSubscription.objects.filter(*args)
+        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
+            recipients.setdefault(sub.user, []).append(match)
+        return recipients
+
+
+def send_bundle_notifications(sender, bundle, **kwargs):
+    recipients = TestRunFilterSubscription.recipients_for_bundle(bundle)
+    domain = '???'
+    try:
+        site = Site.objects.get_current()
+    except (Site.DoesNotExist, ImproperlyConfigured):
+        pass
+    else:
+        domain = site.domain
+    url_prefix = 'http://%s' % domain
+    for user, matches in recipients.items():
+        data = {'bundle': bundle, 'user': user, 'matches': matches, 'url_prefix': url_prefix}
+        mail = render_to_string(
+            'dashboard_app/filter_subscription_mail.txt',
+            data)
+        filter_names = ', '.join(match.filter.name for match in matches)
+        send_mail(
+            "LAVA result notification: " + filter_names, mail,
+            settings.SERVER_EMAIL, [user.email])
+
+
+bundle_was_deserialized.connect(send_bundle_notifications)

=== added file 'dashboard_app/static/css/filter-edit.css'
--- dashboard_app/static/css/filter-edit.css	1970-01-01 00:00:00 +0000
+++ dashboard_app/static/css/filter-edit.css	2012-08-14 02:25:44 +0000
@@ -0,0 +1,10 @@ 
+@import url("../../admin/css/widgets.css");
+div.selector span.helptext { display: none; }
+div.selector h2 { margin: 0; font-size: 11pt; }
+div.selector a { text-decoration: none; }
+div.selector select { height: 10em; }
+div.selector ul.selector-chooser { margin-top: 5.5em; }
+div.selector .selector-chosen select {
+  border: 1px solid rgb(204, 204, 204);
+  border-top: none;
+}

=== added file 'dashboard_app/static/js/filter-edit.js'
--- dashboard_app/static/js/filter-edit.js	1970-01-01 00:00:00 +0000
+++ dashboard_app/static/js/filter-edit.js	2012-08-20 23:55:03 +0000
@@ -0,0 +1,67 @@ 
+var row_number;
+$(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');
+    }
+};
+$("#id_test").change(updateTestCasesFromTest);
+row_number = $("#attribute-table tbody tr").size();
+$("#add-attribute").click(
+    function (e) {
+        e.preventDefault();
+        var body = $("#attribute-table tbody");
+        var row = $("#template-row").clone(true, true);
+        row.show();
+        row.find('.key').attr('id', 'id_attribute_key_' + row_number);
+        row.find('.value').attr('id', 'id_attribute_value_' + row_number);
+        row.find('.key').attr('name', 'attribute_key_' + row_number);
+        row.find('.value').attr('name', 'attribute_value_' + row_number);
+        row_number += 1;
+        body.append(row);
+        row.find(".key").autocomplete(keyAutocompleteConfig);
+        row.find(".value").autocomplete(valueAutocompleteConfig);
+    });
+$("a.delete-row").click(
+    function (e) {
+        e.preventDefault();
+        $(this).closest('tr').remove();
+    });
+var keyAutocompleteConfig = {
+        source: attr_name_completion_url
+    };
+var valueAutocompleteConfig = {
+        source: function (request, response) {
+            var attrName = this.element.closest('tr').find('input.key').val();
+            $.getJSON(
+                attr_value_completion_url,
+                {
+                    'name': attrName,
+                    'term': request.term
+                },
+                function (data) {
+                    response(data);
+                }
+            );
+        }
+    };
+$("tbody .key").autocomplete(keyAutocompleteConfig);
+$("tbody .value").autocomplete(valueAutocompleteConfig);
+});
\ No newline at end of file

=== added file 'dashboard_app/templates/dashboard_app/filter_add.html'
--- dashboard_app/templates/dashboard_app/filter_add.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_add.html	2012-09-03 00:09:09 +0000
@@ -0,0 +1,26 @@ 
+{% extends "dashboard_app/_content.html" %}
+{% load i18n %}
+
+{% block extrahead %}
+{{ block.super }}
+{{ form.media }}
+{% endblock %}
+
+{% block content %}
+{% if form.instance.pk %}
+<h1>[BETA] Edit filter &ldquo;{{ form.instance.name }}&rdquo;…</h1>
+{% else %}
+<h1>[BETA] Add new filter…</h1>
+{% endif %}
+
+<form action="" method="post">
+    {% csrf_token %}
+    {% include "dashboard_app/filter_form.html" %}
+{% if form.instance.pk %}
+    <input type="submit" value="Preview changes">
+{% else %}
+    <input type="submit" value="Preview">
+{% endif %}
+</form>
+
+{% endblock %}

=== added file 'dashboard_app/templates/dashboard_app/filter_delete.html'
--- dashboard_app/templates/dashboard_app/filter_delete.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_delete.html	2012-09-03 00:09:09 +0000
@@ -0,0 +1,14 @@ 
+{% extends "dashboard_app/_content.html" %}
+{% load i18n %}
+
+{% block content %}
+<h1>[BETA] Delete filter {{ filter.name }}</h1>
+
+<form action="" method="POST">
+  {% csrf_token %}
+  Do you really want to delete the filter {{ filter.name }}?
+  <input type="submit" name="yes" value="Yes">
+  <input type="submit" name="no" value="No">
+</form>
+
+{% endblock %}

=== added file 'dashboard_app/templates/dashboard_app/filter_detail.html'
--- dashboard_app/templates/dashboard_app/filter_detail.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_detail.html	2012-09-03 00:09:09 +0000
@@ -0,0 +1,30 @@ 
+{% extends "dashboard_app/_content.html" %}
+{% load i18n %}
+{% load django_tables2 %}
+
+{% block content %}
+
+<h1>[BETA] Filter {{ filter.name }}</h1>
+
+{% include "dashboard_app/filter_summary.html" with summary_data=filter.summary_data %}
+
+{% if filter.owner == request.user %}
+<p>
+  You can <a href="{{ filter.get_absolute_url }}/+edit">edit</a>
+  or <a href="{{ filter.get_absolute_url }}/+delete">delete</a> this filter.
+</p>
+{% endif %}
+
+{% if subscription %}
+<p>
+  <a href="{% url dashboard_app.views.filter_subscribe username=filter.owner.username name=filter.name %}">Manage</a> your subscription to this filter.
+</p>
+{% else %}
+<p>
+  <a href="{% url dashboard_app.views.filter_subscribe username=filter.owner.username name=filter.name %}">Subscribe</a> to this filter.
+</p>
+{% endif %}
+
+{% render_table filter_table %}
+
+{% endblock %}

=== added file 'dashboard_app/templates/dashboard_app/filter_form.html'
--- dashboard_app/templates/dashboard_app/filter_form.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_form.html	2012-08-16 00:14:22 +0000
@@ -0,0 +1,98 @@ 
+    <p>
+      A filter matches test runs by a number of criteria.
+    </p>
+    {{ form.non_field_errors }}
+    <dl>
+      <dt>
+        Metadata:
+      </dt>
+      <dd>
+        {{ form.name.errors }}
+        {{ form.name.label_tag }}: {{ form.name }}
+        <br /><span class="helptext">{{ form.name.help_text|safe }}</span>
+        <br />{{ form.public.errors }}
+        {{ form.public }}{{ form.public.label_tag }}
+        <br /><span class="helptext">{{ form.public.help_text|safe }}</span>
+      </dd>
+      <dt>
+        {{ form.bundle_streams.label_tag }}:
+      </dt>
+      <dd>
+        {{ form.bundle_streams.errors }}
+        <div>{{ form.bundle_streams }}</div>
+        <div style="clear:left">{{ form.bundle_streams.help_text|safe }}</div>
+      </dd>
+      <dt>
+        Attributes:
+      </dt>
+      <dd>
+        <table id="attribute-table">
+          <thead>
+            <tr>
+              <th>
+                Name
+              </th>
+              <th>
+                Value
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            {% for attr in form.attributes %}
+            <tr>
+              <td>
+                <input class="key"
+                       id="id_attribute_key_{{ forloop.counter0 }}"
+                       name="attribute_key_{{ forloop.counter0 }}"
+                       value="{{attr.0}}" />
+              </td>
+              <td>
+                <input class="value"
+                       id="id_attribute_value_{{ forloop.counter0 }}"
+                       name="attribute_value_{{ forloop.counter0 }}"
+                       value="{{attr.1}}" />
+              </td>
+              <td>
+                <a href="#" class="delete-row">remove</a>
+              </td>
+            </tr>
+            {% endfor %}
+          </tbody>
+          <tfoot>
+            <tr style="display:none" id="template-row">
+              <td>
+                <input class="key" />
+              </td>
+              <td>
+                <input class="value" />
+              </td>
+              <td>
+                <a href="#" class="delete-row">remove</a>
+              </td>
+            </tr>
+            <tr>
+              <td colspan="2">
+                <a id="add-attribute" href="#">Add a required attribute</a>
+              </td>
+            </tr>
+          </tfoot>
+        </table>
+        <br /><span class="helptext">
+          A filter can be limited to test runs with particular values for particular <b>attributes</b>.
+        </span>
+      </dd>
+      <dt>
+        Test and test case:
+      </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>
+      </dd>
+    </dl>

=== added file 'dashboard_app/templates/dashboard_app/filter_preview.html'
--- dashboard_app/templates/dashboard_app/filter_preview.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_preview.html	2012-09-03 00:09:09 +0000
@@ -0,0 +1,53 @@ 
+{% extends "dashboard_app/_content.html" %}
+{% load i18n %}
+{% load django_tables2 %}
+
+{% block extrahead %}
+{{ block.super }}
+{{ form.media }}
+{% endblock %}
+
+{% block content %}
+{% if form.instance.pk %}
+<h1>[BETA] Previewing changes to filter &ldquo;{{ form.instance.name }}&rdquo;</h1>
+{% else %}
+<h1>[BETA] Previewing new filter &ldquo;{{ form.name.value }}&rdquo;</h1>
+{% endif %}
+
+{% include "dashboard_app/filter_summary.html" with summary_data=form.summary_data %}
+
+<p>
+  These are the results matched by your filter.
+</p>
+
+{% render_table table %}
+
+<p>
+
+<form action="" method="post">
+  <p>
+    If this is what you expected, you can
+    {% if form.instance.pk %}
+    <input type="submit" name="save" value="save changes"> to the filter.
+    {% else %}
+    <input type="submit" name="save" value="save"> the filter.
+    {% endif %}
+  </p>
+  <p>
+    Otherwise, you can <a href="#" id="edit-link">edit</a> it.
+  </p>
+  {% csrf_token %}
+  <div id="filter-edit" style="display: none">
+    {% include "dashboard_app/filter_form.html" %}
+    <input type="submit" name="preview" value="Preview again">
+  </div>
+</form>
+
+
+<script type="text/javascript">
+$("#edit-link").click(function (e) {
+e.preventDefault();
+$("#filter-edit").show();
+});
+</script>
+{% endblock %}

=== added file 'dashboard_app/templates/dashboard_app/filter_subscribe.html'
--- dashboard_app/templates/dashboard_app/filter_subscribe.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_subscribe.html	2012-09-03 00:09:09 +0000
@@ -0,0 +1,26 @@ 
+{% extends "dashboard_app/_content.html" %}
+{% load i18n %}
+{% load django_tables2 %}
+
+{% block content %}
+
+<h1>[BETA] Subscribe to filter {{ filter.name }}</h1>
+
+<form action="" method="POST">
+  {% csrf_token %}
+  <p>
+    {{ form.level.help_text|safe }}
+    {{ form.level.label_tag }}:
+    {{ form.level }}
+  </p>
+  <p>
+    {% if form.instance.pk %}
+    <input type="submit" name="update" value="Update"/>
+    <input type="submit" name="unsubscribe" value="Unsubscribe"/>
+    {% else %}
+    <input type="submit" name="subscribe" value="Subscribe"/>
+    {% endif %}
+  </p>
+</form>
+
+{% endblock %}

=== added file 'dashboard_app/templates/dashboard_app/filter_subscription_mail.txt'
--- dashboard_app/templates/dashboard_app/filter_subscription_mail.txt	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_subscription_mail.txt	2012-08-23 22:50:32 +0000
@@ -0,0 +1,13 @@ 
+Dear {{ user.first_name }} {{ user.last_name }},
+
+The bundle {{ bundle.content_filename }} was uploaded at {{ bundle.uploaded_on|date:"Y-m-d H:i:s" }} by {% if bundle.uploaded_by %}{{ bundle.uploaded_by }}{% else %}an anonymous user{% endif %}.
+
+It matched the following filters that you are subscribed to:
+
+{% for match in matches %}{{ match.format_for_mail }}{% endfor %}
+You can see more details at:
+
+  {{ url_prefix }}{{ bundle.get_absolute_url }}
+
+LAVA
+Linaro Automated Validation Architecture

=== added file 'dashboard_app/templates/dashboard_app/filter_summary.html'
--- dashboard_app/templates/dashboard_app/filter_summary.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_summary.html	2012-08-17 03:51:42 +0000
@@ -0,0 +1,38 @@ 
+<table>
+  <tr>
+    <th>
+      Bundle streams
+    </th>
+    <td>
+    {% for stream in summary_data.bundle_streams.all %}
+        {{stream.pathname}}{% if not forloop.last %}, {% endif %}
+    {% endfor %}
+    </td>
+  </tr>
+{% if summary_data.attributes %}
+  <tr>
+    <th>
+      Attributes
+    </th>
+    <td>
+    {% for a in summary_data.attributes %}
+    {{ a.0 }} == {{ a.1 }} <br />
+    {% endfor %}
+    </td>
+  </tr>
+{% endif %}
+  <tr>
+    <th>
+      Test case
+    </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 %}
+    </td>
+  </tr>
+</table>

=== added file 'dashboard_app/templates/dashboard_app/filters_list.html'
--- dashboard_app/templates/dashboard_app/filters_list.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filters_list.html	2012-09-03 00:09:09 +0000
@@ -0,0 +1,35 @@ 
+{% extends "dashboard_app/_content.html" %}
+{% load i18n %}
+{% load django_tables2 %}
+
+{% block content %}
+<h1>[BETA] Filters</h1>
+
+<p>
+ A filter matches test runs by a number of criteria.
+</p>
+
+{% if user_filters_table %}
+
+<h2>Your Filters</h2>
+
+{% render_table user_filters_table %}
+
+<p>
+  <a href="{% url dashboard_app.views.filter_add %}">Add new filter…</a>
+</p>
+
+{% else %}
+
+<p>
+  Please log in to see and manage your filters.
+</p>
+
+{% endif %}
+
+<h2>Public Filters</h2>
+
+{% render_table public_filters_table %}
+
+
+{% endblock %}

=== modified file 'dashboard_app/urls.py'
--- dashboard_app/urls.py	2012-07-13 04:12:52 +0000
+++ dashboard_app/urls.py	2012-08-23 22:50:32 +0000
@@ -35,6 +35,17 @@ 
     url(r'^reports/(?P<name>[a-zA-Z0-9-_]+)/$', 'report_detail'),
     url(r'^tests/$', 'test_list'),
     url(r'^tests/(?P<test_id>[^/]+)/$', 'test_detail'),
+    url(r'^filters/$', 'filters_list'),
+    url(r'^filters/\+add$', 'filter_add'),
+    url(r'^filters/\+add-preview-json$', 'filter_preview_json'),
+    url(r'^filters/\+add-cases-for-test-json$', 'filter_add_cases_for_test_json'),
+    url(r'^filters/\+attribute-name-completion-json$', 'filter_attr_name_completion_json'),
+    url(r'^filters/\+attribute-value-completion-json$', 'filter_attr_value_completion_json'),
+    url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)$', 'filter_detail'),
+    url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/json$', 'filter_json'),
+    url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+edit$', 'filter_edit'),
+    url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+subscribe$', 'filter_subscribe'),
+    url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+delete$', 'filter_delete'),
     url(r'^xml-rpc/$', linaro_django_xmlrpc.views.handler, 
         name='dashboard_app.views.dashboard_xml_rpc_handler',
         kwargs={

=== modified file 'dashboard_app/views.py'
--- dashboard_app/views.py	2012-08-30 02:12:14 +0000
+++ dashboard_app/views.py	2012-09-03 00:16:21 +0000
@@ -23,14 +23,20 @@ 
 import re
 import json
 
+from django.conf import settings
+from django.contrib.admin.widgets import FilteredSelectMultiple
 from django.contrib.auth.decorators import login_required
 from django.contrib.sites.models import Site
+from django.core.exceptions import PermissionDenied, ValidationError
 from django.core.urlresolvers import reverse
 from django.db.models.manager import Manager
 from django.db.models.query import QuerySet
+from django import forms
 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
+from django.template import Template, Context
+from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.views.decorators.http import require_POST
 from django.views.generic.list_detail import object_list, object_detail
@@ -53,10 +59,14 @@ 
     Image,
     ImageSet,
     LaunchpadBug,
+    NamedAttribute,
     Tag,
     Test,
+    TestCase,
     TestResult,
     TestRun,
+    TestRunFilter,
+    TestRunFilterSubscription,
     TestingEffort,
 )
 
@@ -434,6 +444,439 @@ 
     searchable_columns = ['test_case__test_case_id']
 
 
+class UserFiltersTable(DataTablesTable):
+
+    name = TemplateColumn('''
+    <a href="{{ record.get_absolute_url }}">{{ record.name }}</a>
+    ''')
+
+    bundle_streams = TemplateColumn('''
+    {% for r in record.bundle_streams.all %}
+        {{r.pathname}} <br />
+    {% endfor %}
+    ''')
+
+    attributes = TemplateColumn('''
+    {% for a in record.attributes.all %}
+    {{ a }}  <br />
+    {% endfor %}
+    ''')
+
+    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 %}
+    ''')
+
+    subscription = Column()
+    def render_subscription(self, record):
+        try:
+            sub = TestRunFilterSubscription.objects.get(
+                user=self.user, filter=record)
+        except TestRunFilterSubscription.DoesNotExist:
+            return "None"
+        else:
+            return sub.get_level_display()
+
+    public = Column()
+
+    def get_queryset(self, user):
+        return TestRunFilter.objects.filter(owner=user)
+
+
+class PublicFiltersTable(UserFiltersTable):
+
+    name = TemplateColumn('''
+    <a href="{{ record.get_absolute_url }}">~{{ record.owner.username }}/{{ record.name }}</a>
+    ''')
+
+    def __init__(self, *args, **kw):
+        super(PublicFiltersTable, self).__init__(*args, **kw)
+        del self.base_columns['public']
+
+    def get_queryset(self):
+        return TestRunFilter.objects.filter(public=True)
+
+
+@BreadCrumb("Filters and Subscriptions", parent=index)
+def filters_list(request):
+    public_filters_table = PublicFiltersTable("public-filters", None)
+    if request.user.is_authenticated():
+        public_filters_table.user = request.user
+        user_filters_table = UserFiltersTable("user-filters", None, params=(request.user,))
+        user_filters_table.user = request.user
+    else:
+        user_filters_table = None
+        del public_filters_table.base_columns['subscription']
+
+    return render_to_response(
+        'dashboard_app/filters_list.html', {
+            'user_filters_table': user_filters_table,
+            'public_filters_table': public_filters_table,
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(
+                filters_list),
+        }, RequestContext(request)
+    )
+
+
+class SpecificCaseColumn(Column):
+    def render(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))
+
+
+class BundleColumn(Column):
+    def render(self, record):
+        return mark_safe('<a href="' + record.bundle.get_absolute_url() + '">' + escape(record.bundle.content_filename) + '</a>')
+
+
+class FilterTable(DataTablesTable):
+    def __init__(self, *args, **kwargs):
+        filter = kwargs['params'][1]
+        data = filter.summary_data
+        super(FilterTable, self).__init__(*args, **kwargs)
+        if len(data['bundle_streams']) == 1:
+            del self.base_columns['bundle_stream']
+        if data['test_case']:
+            del self.base_columns['bundle']
+            del self.base_columns['passes']
+            del self.base_columns['total']
+            self.base_columns['specific_results'].verbose_name = mark_safe(
+                data['test_case'].test_case_id)
+        elif data['test']:
+            del self.base_columns['bundle']
+            del self.base_columns['specific_results']
+        else:
+            del self.base_columns['test_run']
+            self.base_columns['passes']
+            self.base_columns['total']
+            del self.base_columns['specific_results']
+        uploaded_col_index = self.base_columns.keys().index('uploaded_on')
+        self.datatable_opts = self.datatable_opts.copy()
+        self.datatable_opts['aaSorting'] = [[uploaded_col_index, 'desc']]
+        self._compute_queryset(kwargs['params'])
+
+    bundle_stream = Column(accessor='bundle.bundle_stream')
+
+    bundle = BundleColumn(accessor='bundle', sortable=False)
+
+    test_run = TemplateColumn(
+        '<a href="{{ record.test_run.get_absolute_url }}">'
+        '<code>{{ record.test_run.test }} results<code/></a>',
+        accessor="test__test_id",
+        )
+
+    uploaded_on = TemplateColumn(
+        '{{ record.bundle.uploaded_on|date:"Y-m-d H:i:s" }}',
+        accessor='bundle__uploaded_on')
+
+    passes = Column(accessor='pass_count', sortable=False)
+    total = Column(accessor='result_count', sortable=False)
+    specific_results = SpecificCaseColumn(accessor='specific_results', sortable=False)
+    def get_queryset(self, user, filter):
+        return filter.get_test_runs(user)
+
+    datatable_opts = {
+        "sPaginationType": "full_numbers",
+        "iDisplayLength": 25,
+        }
+
+
+def filter_json(request, username, name):
+    filter = TestRunFilter.objects.get(owner__username=username, name=name)
+    return FilterTable.json(request, params=(request.user, filter))
+
+
+class FilterPreviewTable(FilterTable):
+    def get_queryset(self, user, form):
+        return form.get_test_runs(user)
+
+    datatable_opts = FilterTable.datatable_opts.copy()
+    datatable_opts.update({
+        "iDisplayLength": 10,
+        })
+
+
+def filter_preview_json(request):
+    try:
+        filter = TestRunFilter.objects.get(owner=request.user, name=request.GET['name'])
+    except TestRunFilter.DoesNotExist:
+        filter = None
+    form = TestRunFilterForm(request.user, request.GET, instance=filter)
+    if not form.is_valid():
+        raise ValidationError(str(form.errors))
+    return FilterPreviewTable.json(request, params=(request.user, form))
+
+
+@BreadCrumb("Filter ~{username}/{name}", parent=filters_list, needs=['username', 'name'])
+def filter_detail(request, username, name):
+    filter = TestRunFilter.objects.get(owner__username=username, name=name)
+    if not filter.public and filter.owner != request.user:
+        raise PermissionDenied()
+    if not request.user.is_authenticated():
+        subscription = None
+    else:
+        try:
+            subscription = TestRunFilterSubscription.objects.get(
+                user=request.user, filter=filter)
+        except TestRunFilterSubscription.DoesNotExist:
+            subscription = None
+    return render_to_response(
+        'dashboard_app/filter_detail.html', {
+            'filter': filter,
+            'subscription': subscription,
+            'filter_table': FilterTable(
+                "filter-table",
+                reverse(filter_json, kwargs=dict(username=username, name=name)),
+                params=(request.user, filter)),
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(
+                filter_detail, name=name, username=username),
+        }, RequestContext(request)
+    )
+
+
+class TestRunFilterSubscriptionForm(forms.ModelForm):
+    class Meta:
+        model = TestRunFilterSubscription
+        fields = ('level',)
+    def __init__(self, filter, user, *args, **kwargs):
+        super(TestRunFilterSubscriptionForm, self).__init__(*args, **kwargs)
+        self.instance.filter = filter
+        self.instance.user = user
+
+
+@BreadCrumb("Manage Subscription", parent=filter_detail, needs=['name', 'username'])
+@login_required
+def filter_subscribe(request, username, name):
+    filter = TestRunFilter.objects.get(owner__username=username, name=name)
+    if not filter.public and filter.owner != request.user:
+        raise PermissionDenied()
+    try:
+        subscription = TestRunFilterSubscription.objects.get(
+            user=request.user, filter=filter)
+    except TestRunFilterSubscription.DoesNotExist:
+        subscription = None
+    if request.method == "POST":
+        form = TestRunFilterSubscriptionForm(
+            filter, request.user, request.POST, instance=subscription)
+        if form.is_valid():
+            if 'unsubscribe' in request.POST:
+                subscription.delete()
+            else:
+                form.save()
+            return HttpResponseRedirect(filter.get_absolute_url())
+    else:
+        form = TestRunFilterSubscriptionForm(
+            filter, request.user, instance=subscription)
+    return render_to_response(
+        'dashboard_app/filter_subscribe.html', {
+            'filter': filter,
+            'form': form,
+            'subscription': subscription,
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(
+                filter_subscribe, name=name, username=username),
+        }, RequestContext(request)
+    )
+
+
+test_run_filter_head = '''
+<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}dashboard_app/css/filter-edit.css" />
+<script type="text/javascript" src="{% url admin:jsi18n %}"></script>
+<script type="text/javascript">
+var django = {};
+django.jQuery = $;
+var test_case_url = "{% url dashboard_app.views.filter_add_cases_for_test_json %}?test=";
+var attr_name_completion_url = "{% url dashboard_app.views.filter_attr_name_completion_json %}";
+var attr_value_completion_url = "{% url dashboard_app.views.filter_attr_value_completion_json %}";
+</script>
+<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/filter-edit.js"></script>
+'''
+
+
+class TestRunFilterForm(forms.ModelForm):
+    class Meta:
+        model = TestRunFilter
+        exclude = ('owner',)
+        widgets = {
+            'bundle_streams': FilteredSelectMultiple("Bundle Streams", False),
+            }
+
+    @property
+    def media(self):
+        super_media = str(super(TestRunFilterForm, self).media)
+        return mark_safe(Template(test_run_filter_head).render(
+            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:
+            self.instance.validate_unique()
+        except ValidationError, e:
+            if e.message_dict.values() == [[
+                u'Test run filter with this Owner and Name already exists.']]:
+                raise ValidationError("You already have a filter with this name")
+            else:
+                raise
+
+    def save(self, commit=True, **kwargs):
+        instance = super(TestRunFilterForm, self).save(commit=commit, **kwargs)
+        if commit:
+            instance.attributes.all().delete()
+            for (name, value) in self.attributes:
+                instance.attributes.create(name=name, value=value)
+        return instance
+
+    @property
+    def summary_data(self):
+        data = self.cleaned_data.copy()
+        data['attributes'] = self.attributes
+        return data
+
+    def __init__(self, user, *args, **kwargs):
+        super(TestRunFilterForm, self).__init__(*args, **kwargs)
+        self.instance.owner = user
+        self.fields['bundle_streams'].queryset = BundleStream.objects.accessible_by_principal(user)
+        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')
+
+    @property
+    def attributes(self):
+        if not self.is_bound and self.instance.pk:
+            return self.instance.attributes.values_list('name', 'value')
+        else:
+            attributes = []
+            for (var, value) in self.data.iteritems():
+                if var.startswith('attribute_key_'):
+                    index = int(var[len('attribute_key_'):])
+                    attr_value = self.data['attribute_value_' + str(index)]
+                    attributes.append((index, value, attr_value))
+
+            attributes.sort()
+            attributes = [a[1:] for a in attributes]
+            return attributes
+
+    def get_test_runs(self, user):
+        assert self.is_valid(), self.errors
+        filter = self.save(commit=False)
+        return filter.get_test_runs_impl(
+            user, self.cleaned_data['bundle_streams'], self.attributes)
+
+
+def filter_form(request, bread_crumb_trail, instance=None):
+    if request.method == 'POST':
+        form = TestRunFilterForm(request.user, request.POST, instance=instance)
+
+        if form.is_valid():
+            if 'save' in request.POST:
+                filter = form.save()
+                return HttpResponseRedirect(filter.get_absolute_url())
+            else:
+                c = request.POST.copy()
+                c.pop('csrfmiddlewaretoken', None)
+                return render_to_response(
+                    'dashboard_app/filter_preview.html', {
+                        'bread_crumb_trail': bread_crumb_trail,
+                        'form': form,
+                        'table': FilterPreviewTable(
+                            'filter-preview',
+                            reverse(filter_preview_json) + '?' + c.urlencode(),
+                            params=(request.user, form)),
+                    }, RequestContext(request))
+    else:
+        form = TestRunFilterForm(request.user, instance=instance)
+    return render_to_response(
+        'dashboard_app/filter_add.html', {
+            'bread_crumb_trail': bread_crumb_trail,
+            'form': form,
+        }, RequestContext(request))
+
+
+@BreadCrumb("Add new filter", parent=filters_list)
+def filter_add(request):
+    return filter_form(
+        request,
+        BreadCrumbTrail.leading_to(filter_add))
+
+
+@BreadCrumb("Edit", parent=filter_detail, needs=['name', 'username'])
+def filter_edit(request, username, name):
+    if request.user.username != username:
+        raise PermissionDenied()
+    filter = TestRunFilter.objects.get(owner=request.user, name=name)
+    return filter_form(
+        request,
+        BreadCrumbTrail.leading_to(filter_edit, name=name, username=username),
+        instance=filter)
+
+
+@BreadCrumb("Delete", parent=filter_detail, needs=['name', 'username'])
+def filter_delete(request, username, name):
+    if request.user.username != username:
+        raise PermissionDenied()
+    filter = TestRunFilter.objects.get(owner=request.user, name=name)
+    if request.method == "POST":
+        if 'yes' in request.POST:
+            filter.delete()
+            return HttpResponseRedirect(reverse(filters_list))
+        else:
+            return HttpResponseRedirect(filter.get_absolute_url())
+    return render_to_response(
+        'dashboard_app/filter_delete.html', {
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(filter_delete, name=name, username=username),
+            'filter': filter,
+        }, RequestContext(request))
+
+
+def filter_add_cases_for_test_json(request):
+    test = Test.objects.get(test_id=request.GET['test'])
+    result = TestCase.objects.filter(test=test).order_by('test_case_id').values('test_case_id', 'id')
+    return HttpResponse(
+        json.dumps(list(result)),
+        mimetype='application/json')
+
+
+def filter_attr_name_completion_json(request):
+    term = request.GET['term']
+    result = NamedAttribute.objects.filter(
+        name__startswith=term).distinct('name').order_by('name').values_list('name', flat=True)
+    return HttpResponse(
+        json.dumps(list(result)),
+        mimetype='application/json')
+
+
+def filter_attr_value_completion_json(request):
+    name = request.GET['name']
+    term = request.GET['term']
+    result = NamedAttribute.objects.filter(
+        name=name,
+        value__startswith=term).distinct('value').order_by('value').values_list('value', flat=True)
+    return HttpResponse(
+        json.dumps(list(result)),
+        mimetype='application/json')
+
+
 def test_run_detail_test_json(request, pathname, content_sha1, analyzer_assigned_uuid):
     test_run = get_restricted_object_or_404(
         TestRun, lambda test_run: test_run.bundle.bundle_stream,

=== modified file 'doc/changes.rst'
--- doc/changes.rst	2012-08-28 05:25:49 +0000
+++ doc/changes.rst	2012-08-30 00:00:01 +0000
@@ -6,6 +6,7 @@ 
 Version 0.21
 ============
 * Unreleased
+* Add the concept of a test run filter.
 
 .. _version_0_20:
 
@@ -21,7 +22,6 @@ 
 
 Version 0.19
 ============
-
 * Add image status views and models for use by the QA services team.
 * Allow linking test runs to launchpad bugs from the image status view.