Note: This change is to support a project that I am working on. You should see no change in the behavior of your current Autotest installations.

-----

Implement the models and set up the RPC framework for the Test Planner

Signed-off-by: James Ren <jamesren@google.com>



git-svn-id: http://test.kernel.org/svn/autotest/trunk@4039 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/frontend/afe/frontend_test_utils.py b/frontend/afe/frontend_test_utils.py
index 51b9bcb..efd6100 100644
--- a/frontend/afe/frontend_test_utils.py
+++ b/frontend/afe/frontend_test_utils.py
@@ -89,11 +89,12 @@
         thread_local.set_user(self.user)
 
 
-    def _frontend_common_setup(self):
+    def _frontend_common_setup(self, fill_data=True):
         self.god = mock.mock_god()
         self._open_test_db()
         self._setup_dummy_user()
-        self._fill_in_test_data()
+        if fill_data:
+            self._fill_in_test_data()
 
 
     def _frontend_common_teardown(self):
diff --git a/frontend/afe/model_logic.py b/frontend/afe/model_logic.py
index d370bf2..a798416 100644
--- a/frontend/afe/model_logic.py
+++ b/frontend/afe/model_logic.py
@@ -943,3 +943,50 @@
             self.delete_attribute(attribute)
         else:
             self.set_attribute(attribute, value)
+
+
+class ModelWithHashManager(dbmodels.Manager):
+    """Manager for use with the ModelWithHash abstract model class"""
+
+    def create(self, **kwargs):
+        raise Exception('ModelWithHash manager should use get_or_create() '
+                        'instead of create()')
+
+
+    def get_or_create(self, **kwargs):
+        kwargs['the_hash'] = self.model._compute_hash(**kwargs)
+        return super(ModelWithHashManager, self).get_or_create(**kwargs)
+
+
+class ModelWithHash(dbmodels.Model):
+    """Superclass with methods for dealing with a hash column"""
+
+    the_hash = dbmodels.CharField(max_length=40, unique=True)
+
+    objects = ModelWithHashManager()
+
+    class Meta:
+        abstract = True
+
+
+    @classmethod
+    def _compute_hash(cls, **kwargs):
+        raise NotImplementedError('Subclasses must override _compute_hash()')
+
+
+    def save(self, force_insert=False, **kwargs):
+        """Prevents saving the model in most cases
+
+        We want these models to be immutable, so the generic save() operation
+        will not work. These models should be instantiated through their the
+        model.objects.get_or_create() method instead.
+
+        The exception is that save(force_insert=True) will be allowed, since
+        that creates a new row. However, the preferred way to make instances of
+        these models is through the get_or_create() method.
+        """
+        if not force_insert:
+            # Allow a forced insert to happen; if it's a duplicate, the unique
+            # constraint will catch it later anyways
+            raise Exception('ModelWithHash is immutable')
+        super(ModelWithHash, self).save(force_insert=force_insert, **kwargs)
diff --git a/frontend/afe/rpc_utils.py b/frontend/afe/rpc_utils.py
index eb86cf4..26555d9 100644
--- a/frontend/afe/rpc_utils.py
+++ b/frontend/afe/rpc_utils.py
@@ -5,7 +5,7 @@
 
 __author__ = 'showard@google.com (Steve Howard)'
 
-import datetime, os
+import datetime, os, sys
 import django.http
 from autotest_lib.frontend.afe import models, model_logic
 
@@ -620,3 +620,19 @@
             special_task_index += 1
 
     return interleaved_entries
+
+
+def get_sha1_hash(source):
+    """Gets the SHA-1 hash of the source string
+
+    @param source The string to hash
+    """
+    if sys.version_info < (2,5):
+        import sha
+        digest = sha.new()
+    else:
+        import hashlib
+        digest = hashlib.sha1()
+
+    digest.update(source)
+    return digest.hexdigest()
diff --git a/frontend/afe/urls.py b/frontend/afe/urls.py
index 78cd5fb..976ff8b 100644
--- a/frontend/afe/urls.py
+++ b/frontend/afe/urls.py
@@ -1,34 +1,19 @@
 from django.conf.urls.defaults import *
-import os
-from autotest_lib.frontend import settings
+import common
+from autotest_lib.frontend import settings, urls_common
 from autotest_lib.frontend.afe.feeds import feed
 
 feeds = {
     'jobs' : feed.JobFeed
 }
 
-pattern_list = [
-        (r'^(?:|noauth/)rpc/', 'frontend.afe.views.handle_rpc'),
-        (r'^rpc_doc', 'frontend.afe.views.rpc_documentation'),
-    ]
+pattern_list, debug_pattern_list = (
+        urls_common.generate_pattern_lists('frontend.afe', 'AfeClient'))
 
-debug_pattern_list = [
-    (r'^model_doc/', 'frontend.afe.views.model_documentation'),
-    # for GWT hosted mode
-    (r'^(?P<forward_addr>autotest.*)', 'frontend.afe.views.gwt_forward'),
-    # for GWT compiled files
-    (r'^client/(?P<path>.*)$', 'django.views.static.serve',
-     {'document_root': os.path.join(os.path.dirname(__file__), '..', 'client',
-                                    'www')}),
-    # redirect / to compiled client
-    (r'^$', 'django.views.generic.simple.redirect_to',
-     {'url': 'client/autotest.AfeClient/AfeClient.html'}),
-
-    # Job feeds
-    (r'^feeds/(?P<url>.*)/$', 'frontend.afe.feeds.feed.feed_view',
-     {'feed_dict': feeds})
-
-]
+# Job feeds
+debug_pattern_list.append((
+        r'^feeds/(?P<url>.*)/$', 'frontend.afe.feeds.feed.feed_view',
+        {'feed_dict': feeds}))
 
 if settings.DEBUG:
     pattern_list += debug_pattern_list
diff --git a/frontend/afe/views.py b/frontend/afe/views.py
index 32beb7a..6c938d9 100644
--- a/frontend/afe/views.py
+++ b/frontend/afe/views.py
@@ -4,6 +4,7 @@
 from django.http import HttpResponseServerError
 from django.template import Context, loader
 from autotest_lib.client.common_lib import utils
+from autotest_lib.frontend import views_common
 from autotest_lib.frontend.afe import models, rpc_handler, rpc_interface
 from autotest_lib.frontend.afe import rpc_utils
 
@@ -26,13 +27,9 @@
 
 
 def model_documentation(request):
-    doc = '<h2>Models</h2>\n'
-    for model_name in ('Label', 'Host', 'Test', 'User', 'AclGroup', 'Job',
-                       'AtomicGroup'):
-        model_class = getattr(models, model_name)
-        doc += '<h3>%s</h3>\n' % model_name
-        doc += '<pre>\n%s</pre>\n' % model_class.__doc__
-    return HttpResponse(doc)
+    model_names = ('Label', 'Host', 'Test', 'User', 'AclGroup', 'Job',
+                   'AtomicGroup')
+    return views_common.model_documentation(models, model_names)
 
 
 def redirect_with_extra_data(request, url, **kwargs):
diff --git a/frontend/migrations/045_test_planner_framework.py b/frontend/migrations/045_test_planner_framework.py
new file mode 100644
index 0000000..df5e0ec
--- /dev/null
+++ b/frontend/migrations/045_test_planner_framework.py
@@ -0,0 +1,242 @@
+UP_SQL = """\
+BEGIN;
+
+SET storage_engine = InnoDB;
+
+CREATE TABLE `planner_plans` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `name` varchar(255) NOT NULL UNIQUE,
+    `label_override` varchar(255) NULL,
+    `support` longtext NOT NULL,
+    `complete` bool NOT NULL,
+    `dirty` bool NOT NULL,
+    `initialized` bool NOT NULL
+)
+;
+
+
+CREATE TABLE `planner_hosts` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `host_id` integer NOT NULL,
+    `complete` bool NOT NULL,
+    `blocked` bool NOT NULL
+)
+;
+ALTER TABLE `planner_hosts` ADD CONSTRAINT hosts_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+ALTER TABLE `planner_hosts` ADD CONSTRAINT hosts_host_id_fk FOREIGN KEY (`host_id`) REFERENCES `afe_hosts` (`id`);
+
+
+CREATE TABLE `planner_test_control_files` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `the_hash` varchar(40) NOT NULL UNIQUE,
+    `contents` longtext NOT NULL
+)
+;
+
+
+CREATE TABLE `planner_tests` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `control_file_id` integer NOT NULL,
+    `execution_order` integer NOT NULL
+)
+;
+ALTER TABLE `planner_tests` ADD CONSTRAINT tests_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+ALTER TABLE `planner_tests` ADD CONSTRAINT tests_control_file_id_fk FOREIGN KEY (`control_file_id`) REFERENCES `planner_test_control_files` (`id`);
+
+
+CREATE TABLE `planner_test_jobs` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `test_id` integer NOT NULL,
+    `afe_job_id` integer NOT NULL
+)
+;
+ALTER TABLE `planner_test_jobs` ADD CONSTRAINT test_jobs_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+ALTER TABLE `planner_test_jobs` ADD CONSTRAINT test_jobs_test_id_fk FOREIGN KEY (`test_id`) REFERENCES `planner_tests` (`id`);
+ALTER TABLE `planner_test_jobs` ADD CONSTRAINT test_jobs_afe_job_id_fk FOREIGN KEY (`afe_job_id`) REFERENCES `afe_jobs` (`id`);
+CREATE TABLE `planner_bugs` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `external_uid` varchar(255) NOT NULL UNIQUE
+)
+;
+
+
+CREATE TABLE `planner_test_runs` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `test_job_id` integer NOT NULL,
+    `tko_test_id` integer(10) UNSIGNED NOT NULL,
+    `status` varchar(16) NOT NULL,
+    `finalized` bool NOT NULL,
+    `seen` bool NOT NULL,
+    `triaged` bool NOT NULL
+)
+;
+ALTER TABLE `planner_test_runs` ADD CONSTRAINT test_runs_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+ALTER TABLE `planner_test_runs` ADD CONSTRAINT test_runs_test_job_id_fk FOREIGN KEY (`test_job_id`) REFERENCES `planner_test_jobs` (`id`);
+ALTER TABLE `planner_test_runs` ADD CONSTRAINT test_runs_tko_test_id_fk FOREIGN KEY (`tko_test_id`) REFERENCES `tko.tko_tests` (`test_idx`);
+
+
+CREATE TABLE `planner_data_types` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `name` varchar(255) NOT NULL,
+    `db_table` varchar(255) NOT NULL
+)
+;
+
+
+CREATE TABLE `planner_history` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `action_id` integer NOT NULL,
+    `user_id` integer NOT NULL,
+    `data_type_id` integer NOT NULL,
+    `object_id` integer NOT NULL,
+    `old_object_repr` longtext NOT NULL,
+    `new_object_repr` longtext NOT NULL,
+    `time` datetime NOT NULL
+)
+;
+ALTER TABLE `planner_history` ADD CONSTRAINT history_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+ALTER TABLE `planner_history` ADD CONSTRAINT history_user_id_fk FOREIGN KEY (`user_id`) REFERENCES `afe_users` (`id`);
+ALTER TABLE `planner_history` ADD CONSTRAINT history_data_type_id_fk FOREIGN KEY (`data_type_id`) REFERENCES `planner_data_types` (`id`);
+
+
+CREATE TABLE `planner_saved_objects` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `user_id` integer NOT NULL,
+    `type` varchar(16) NOT NULL,
+    `name` varchar(255) NOT NULL,
+    `encoded_object` longtext NOT NULL,
+    UNIQUE (`user_id`, `type`, `name`)
+)
+;
+ALTER TABLE `planner_saved_objects` ADD CONSTRAINT saved_objects_user_id_fk FOREIGN KEY (`user_id`) REFERENCES `afe_users` (`id`);
+
+
+CREATE TABLE `planner_custom_queries` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `query` longtext NOT NULL
+)
+;
+ALTER TABLE `planner_custom_queries` ADD CONSTRAINT custom_queries_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+
+
+CREATE TABLE `planner_keyvals` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `the_hash` varchar(40) NOT NULL UNIQUE,
+    `key` varchar(1024) NOT NULL,
+    `value` varchar(1024) NOT NULL
+)
+;
+
+
+CREATE TABLE `planner_autoprocess` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `condition` longtext NOT NULL,
+    `enabled` bool NOT NULL,
+    `reason_override` varchar(255) NULL
+)
+;
+ALTER TABLE `planner_autoprocess` ADD CONSTRAINT autoprocess_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+
+
+CREATE TABLE `planner_plan_owners` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `plan_id` integer NOT NULL,
+    `user_id` integer NOT NULL,
+    UNIQUE (`plan_id`, `user_id`)
+)
+;
+ALTER TABLE `planner_plan_owners` ADD CONSTRAINT plan_owners_plan_id_fk FOREIGN KEY (`plan_id`) REFERENCES `planner_plans` (`id`);
+ALTER TABLE `planner_plan_owners` ADD CONSTRAINT plan_owners_user_id_fk FOREIGN KEY (`user_id`) REFERENCES `afe_users` (`id`);
+
+
+CREATE TABLE `planner_test_run_bugs` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `testrun_id` integer NOT NULL,
+    `bug_id` integer NOT NULL,
+    UNIQUE (`testrun_id`, `bug_id`)
+)
+;
+ALTER TABLE `planner_test_run_bugs` ADD CONSTRAINT test_run_bugs_testrun_id_fk FOREIGN KEY (`testrun_id`) REFERENCES `planner_test_runs` (`id`);
+ALTER TABLE `planner_test_run_bugs` ADD CONSTRAINT test_run_bugs_bug_id_fk FOREIGN KEY (`bug_id`) REFERENCES `planner_bugs` (`id`);
+
+
+CREATE TABLE `planner_autoprocess_labels` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `autoprocess_id` integer NOT NULL,
+    `testlabel_id` integer NOT NULL,
+    UNIQUE (`autoprocess_id`, `testlabel_id`)
+)
+;
+ALTER TABLE `planner_autoprocess_labels` ADD CONSTRAINT autoprocess_labels_autoprocess_id_fk FOREIGN KEY (`autoprocess_id`) REFERENCES `planner_autoprocess` (`id`);
+ALTER TABLE `planner_autoprocess_labels` ADD CONSTRAINT autoprocess_labels_testlabel_id_fk FOREIGN KEY (`testlabel_id`) REFERENCES `tko.tko_test_labels` (`id`);
+
+
+CREATE TABLE `planner_autoprocess_keyvals` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `autoprocess_id` integer NOT NULL,
+    `keyval_id` integer NOT NULL,
+    UNIQUE (`autoprocess_id`, `keyval_id`)
+)
+;
+ALTER TABLE `planner_autoprocess_keyvals` ADD CONSTRAINT autoprocess_keyvals_autoprocess_id_fk FOREIGN KEY (`autoprocess_id`) REFERENCES `planner_autoprocess` (`id`);
+ALTER TABLE `planner_autoprocess_keyvals` ADD CONSTRAINT autoprocess_keyvals_keyval_id_fk FOREIGN KEY (`keyval_id`) REFERENCES `planner_keyvals` (`id`);
+
+
+CREATE TABLE `planner_autoprocess_bugs` (
+    `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY,
+    `autoprocess_id` integer NOT NULL,
+    `bug_id` integer NOT NULL,
+    UNIQUE (`autoprocess_id`, `bug_id`)
+)
+;
+ALTER TABLE `planner_autoprocess_bugs` ADD CONSTRAINT autoprocess_bugs_autoprocess_id_fk FOREIGN KEY (`autoprocess_id`) REFERENCES `planner_autoprocess` (`id`);
+ALTER TABLE `planner_autoprocess_bugs` ADD CONSTRAINT autoprocess_bugs_bug_id_fk FOREIGN KEY (`bug_id`) REFERENCES `planner_bugs` (`id`);
+
+
+CREATE INDEX `planner_hosts_plan_id` ON `planner_hosts` (`plan_id`);
+CREATE INDEX `planner_hosts_host_id` ON `planner_hosts` (`host_id`);
+CREATE INDEX `planner_tests_plan_id` ON `planner_tests` (`plan_id`);
+CREATE INDEX `planner_tests_control_file_id` ON `planner_tests` (`control_file_id`);
+CREATE INDEX `planner_test_jobs_plan_id` ON `planner_test_jobs` (`plan_id`);
+CREATE INDEX `planner_test_jobs_test_id` ON `planner_test_jobs` (`test_id`);
+CREATE INDEX `planner_test_jobs_afe_job_id` ON `planner_test_jobs` (`afe_job_id`);
+CREATE INDEX `planner_test_runs_plan_id` ON `planner_test_runs` (`plan_id`);
+CREATE INDEX `planner_test_runs_test_job_id` ON `planner_test_runs` (`test_job_id`);
+CREATE INDEX `planner_test_runs_tko_test_id` ON `planner_test_runs` (`tko_test_id`);
+CREATE INDEX `planner_history_plan_id` ON `planner_history` (`plan_id`);
+CREATE INDEX `planner_history_user_id` ON `planner_history` (`user_id`);
+CREATE INDEX `planner_history_data_type_id` ON `planner_history` (`data_type_id`);
+CREATE INDEX `planner_saved_objects_user_id` ON `planner_saved_objects` (`user_id`);
+CREATE INDEX `planner_custom_queries_plan_id` ON `planner_custom_queries` (`plan_id`);
+CREATE INDEX `planner_autoprocess_plan_id` ON `planner_autoprocess` (`plan_id`);
+
+COMMIT;
+"""
+
+DOWN_SQL = """\
+DROP TABLE IF EXISTS planner_autoprocess_labels;
+DROP TABLE IF EXISTS planner_autoprocess_bugs;
+DROP TABLE IF EXISTS planner_autoprocess_keyvals;
+DROP TABLE IF EXISTS planner_autoprocess;
+DROP TABLE IF EXISTS planner_custom_queries;
+DROP TABLE IF EXISTS planner_saved_objects;
+DROP TABLE IF EXISTS planner_history;
+DROP TABLE IF EXISTS planner_data_types;
+DROP TABLE IF EXISTS planner_hosts;
+DROP TABLE IF EXISTS planner_keyvals;
+DROP TABLE IF EXISTS planner_plan_owners;
+DROP TABLE IF EXISTS planner_test_run_bugs;
+DROP TABLE IF EXISTS planner_test_runs;
+DROP TABLE IF EXISTS planner_test_jobs;
+DROP TABLE IF EXISTS planner_tests;
+DROP TABLE IF EXISTS planner_test_control_files;
+DROP TABLE IF EXISTS planner_bugs;
+DROP TABLE IF EXISTS planner_plans;
+"""
diff --git a/frontend/planner/common.py b/frontend/planner/common.py
new file mode 100644
index 0000000..1edf302
--- /dev/null
+++ b/frontend/planner/common.py
@@ -0,0 +1,8 @@
+import os, sys
+dirname = os.path.dirname(sys.modules[__name__].__file__)
+autotest_dir = os.path.abspath(os.path.join(dirname, '..', '..'))
+client_dir = os.path.join(autotest_dir, "client")
+sys.path.insert(0, client_dir)
+import setup_modules
+sys.path.pop(0)
+setup_modules.setup(base_path=autotest_dir, root_module_name="autotest_lib")
diff --git a/frontend/planner/models.py b/frontend/planner/models.py
new file mode 100644
index 0000000..a7e8af7
--- /dev/null
+++ b/frontend/planner/models.py
@@ -0,0 +1,363 @@
+from django.db import models as dbmodels
+import common
+from autotest_lib.frontend.afe import models as afe_models
+from autotest_lib.frontend.afe import model_logic, rpc_utils
+from autotest_lib.new_tko.tko import models as tko_models
+from autotest_lib.client.common_lib import enum
+
+
+class Plan(dbmodels.Model):
+    """A test plan
+
+    Required:
+        name: Plan name, unique
+        complete: True if the plan is completed
+        dirty: True if the plan has been changed since the execution engine has
+               last seen it
+        initialized: True if the plan has started
+
+    Optional:
+        label_override: A label to apply to each Autotest job.
+        support: The global support object to apply to this plan
+    """
+    name = dbmodels.CharField(max_length=255, unique=True)
+    label_override = dbmodels.CharField(max_length=255, null=True, blank=True)
+    support = dbmodels.TextField(blank=True)
+    complete = dbmodels.BooleanField(default=False)
+    dirty = dbmodels.BooleanField(default=False)
+    initialized = dbmodels.BooleanField(default=False)
+
+    owners = dbmodels.ManyToManyField(afe_models.User,
+                                      db_table='planner_plan_owners')
+    hosts = dbmodels.ManyToManyField(afe_models.Host, through='Host')
+
+    class Meta:
+        db_table = 'planner_plans'
+
+
+    def __unicode__(self):
+        return unicode(self.name)
+
+
+class ModelWithPlan(dbmodels.Model):
+    """Superclass for models that have a plan_id
+
+    Required:
+        plan: The associated test plan
+    """
+    plan = dbmodels.ForeignKey(Plan)
+
+    class Meta:
+        abstract = True
+
+
+    def __unicode__(self):
+        return u'%s (%s)' % (self._get_details_unicode(), self.plan.name)
+
+
+    def _get_details_unicode(self):
+        """Gets the first part of the unicode string
+
+        subclasses must override this method
+        """
+        raise NotImplementedError(
+                'Subclasses must override _get_details_unicode()')
+
+
+class Host(ModelWithPlan):
+    """A plan host
+
+    Required:
+        host: The AFE host
+        complete: True if and only if this host is finished in the test plan
+        blocked: True if and only if the host is blocked (not executing tests)
+    """
+    host = dbmodels.ForeignKey(afe_models.Host)
+    complete = dbmodels.BooleanField(default=False)
+    blocked = dbmodels.BooleanField(default=False)
+
+    class Meta:
+        db_table = 'planner_hosts'
+
+
+    def _get_details_unicode(self):
+        return 'Host: %s' % host.hostname
+
+
+class ControlFile(model_logic.ModelWithHash):
+    """A control file. Immutable once added to the table
+
+    Required:
+        contents: The text of the control file
+
+    Others:
+        control_hash: The SHA1 hash of the control file, for duplicate detection
+                      and fast search
+    """
+    contents = dbmodels.TextField()
+
+    class Meta:
+        db_table = 'planner_test_control_files'
+
+
+    @classmethod
+    def _compute_hash(cls, **kwargs):
+        return rpc_utils.get_sha1_hash(kwargs['contents'])
+
+
+    def __unicode__(self):
+        return u'Control file id %s (SHA1: %s)' % (self.id, self.control_hash)
+
+
+class Test(ModelWithPlan):
+    """A planned test
+
+    Required:
+        test_control_file: The control file to run
+        execution_order: An integer describing when this test should be run in
+                         the test plan
+    """
+    control_file = dbmodels.ForeignKey(ControlFile)
+    execution_order = dbmodels.IntegerField(blank=True)
+
+    class Meta:
+        db_table = 'planner_tests'
+        ordering = ('execution_order',)
+
+
+    def _get_details_unicode(self):
+        return 'Planned test - Control file id %s' % test_control_file.id
+
+
+class Job(ModelWithPlan):
+    """Represents an Autotest job initiated for a test plan
+
+    Required:
+        test: The Test associated with this Job
+        afe_job: The Autotest job
+    """
+    test = dbmodels.ForeignKey(Test)
+    afe_job = dbmodels.ForeignKey(afe_models.Job)
+
+    class Meta:
+        db_table = 'planner_test_jobs'
+
+
+    def _get_details_unicode(self):
+        return 'AFE job %s' % afe_job.id
+
+
+class Bug(dbmodels.Model):
+    """Represents a bug ID
+
+    Required:
+        external_uid: External unique ID for the bug
+    """
+    external_uid = dbmodels.CharField(max_length=255, unique=True)
+
+    class Meta:
+        db_table = 'planner_bugs'
+
+
+    def __unicode__(self):
+        return u'Bug external ID %s' % self.external_uid
+
+
+class TestRun(ModelWithPlan):
+    """An individual test run from an Autotest job for the test plan.
+
+    Each Job object may have multiple TestRun objects associated with it.
+
+    Required:
+        test_job: The Job object associated with this TestRun
+        tko_test: The TKO Test associated with this TestRun
+        status: One of 'Active', 'Passed', 'Failed'
+        finalized: True if and only if the TestRun is ready to be shown in
+                   triage
+        invalidated: True if and only if a user has decided to invalidate this
+                     TestRun's results
+        seen: True if and only if a user has marked this TestRun as "seen"
+        triaged: True if and only if the TestRun no longer requires any user
+                 intervention
+
+    Optional:
+        bugs: Bugs filed that a relevant to this run
+    """
+    Status = enum.Enum('Active', 'Passed', 'Failed', string_values=True)
+
+    test_job = dbmodels.ForeignKey(Job)
+    tko_test = dbmodels.ForeignKey(tko_models.Test)
+    status = dbmodels.CharField(max_length=16, choices=Status.choices())
+    finalized = dbmodels.BooleanField(default=False)
+    seen = dbmodels.BooleanField(default=False)
+    triaged = dbmodels.BooleanField(default=False)
+
+    bugs = dbmodels.ManyToManyField(Bug, null=True,
+                                    db_table='planner_test_run_bugs')
+
+    class Meta:
+        db_table = 'planner_test_runs'
+
+
+    def _get_details_unicode(self):
+        return 'Test Run: %s' % self.id
+
+
+class DataType(dbmodels.Model):
+    """Encodes the data model types
+
+    For use in the history table, to identify the type of object that was
+    changed.
+
+    Required:
+        name: The name of the data type
+        db_table: The name of the database table that stores this type
+    """
+    name = dbmodels.CharField(max_length=255)
+    db_table = dbmodels.CharField(max_length=255)
+
+    class Meta:
+        db_table = 'planner_data_types'
+
+
+    def __unicode__(self):
+        return u'Data type %s (stored in table %s)' % (self.name, self.db_table)
+
+
+class History(ModelWithPlan):
+    """Represents a history action
+
+    Required:
+        action_id: An arbitrary ID that uniquely identifies the user action
+                   related to the history entry. One user action may result in
+                   multiple history entries
+        user: The user who initiated the change
+        data_type: The type of object that was changed
+        object_id: Value of the primary key field for the changed object
+        old_object_repr: A string representation of the object before the change
+        new_object_repr: A string representation of the object after the change
+
+    Others:
+        time: A timestamp. Automatically generated.
+    """
+    action_id = dbmodels.IntegerField()
+    user = dbmodels.ForeignKey(afe_models.User)
+    data_type = dbmodels.ForeignKey(DataType)
+    object_id = dbmodels.IntegerField()
+    old_object_repr = dbmodels.TextField(blank=True)
+    new_object_repr = dbmodels.TextField(blank=True)
+
+    time = dbmodels.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        db_table = 'planner_history'
+
+
+    def _get_details_unicode(self):
+        return 'History entry: %s => %s' % (self.old_object_repr,
+                                            self.new_object_repr)
+
+
+class SavedObject(dbmodels.Model):
+    """A saved object that can be recalled at certain points in the UI
+
+    Required:
+        user: The creator of the object
+        object_type: One of 'support', 'triage', 'autoprocess', 'custom_query'
+        name: The name given to the object
+        encoded_object: The actual object
+    """
+    Type = enum.Enum('support', 'triage', 'autoprocess', 'custom_query',
+                     string_values=True)
+
+    user = dbmodels.ForeignKey(afe_models.User)
+    object_type = dbmodels.CharField(max_length=16,
+                                     choices=Type.choices(), db_column='type')
+    name = dbmodels.CharField(max_length=255)
+    encoded_object = dbmodels.TextField()
+
+    class Meta:
+        db_table = 'planner_saved_objects'
+        unique_together = ('user', 'object_type', 'name')
+
+
+    def __unicode__(self):
+        return u'Saved %s object: %s, by %s' % (self.object_type, self.name,
+                                                self.user.login)
+
+
+class CustomQuery(ModelWithPlan):
+    """A custom SQL query for the triage page
+
+    Required:
+        query: the SQL WHERE clause to attach to the main query
+    """
+    query = dbmodels.TextField()
+
+    class Meta:
+        db_table = 'planner_custom_queries'
+
+
+    def _get_details_unicode(self):
+        return 'Custom Query: %s' % self.query
+
+
+class KeyVal(model_logic.ModelWithHash):
+    """Represents a keyval. Immutable once added to the table.
+
+    Required:
+        key: The key
+        value: The value
+
+    Others:
+        keyval_hash: The result of SHA1(SHA1(key) ++ value), for duplicate
+                     detection and fast search.
+    """
+    key = dbmodels.CharField(max_length=1024)
+    value = dbmodels.CharField(max_length=1024)
+
+    class Meta:
+        db_table = 'planner_keyvals'
+
+
+    @classmethod
+    def _compute_hash(cls, **kwargs):
+        round1 = rpc_utils.get_sha1_hash(kwargs['key'])
+        return rpc_utils.get_sha1_hash(round1 + kwargs['value'])
+
+
+    def __unicode__(self):
+        return u'Keyval: %s = %s' % (self.key, self.value)
+
+
+class AutoProcess(ModelWithPlan):
+    """An autoprocessing directive to perform on test runs that enter triage
+
+    Required:
+        condition: A SQL WHERE clause. The autoprocessing will be applied if the
+                   test run matches this condition
+        enabled: If this is False, this autoprocessing entry will not be applied
+
+    Optional:
+        labels: Labels to apply to the TKO test
+        keyvals: Keyval overrides to apply to the TKO test
+        bugs: Bugs filed that a relevant to this run
+        reason_override: Override for the AFE reason
+    """
+    condition = dbmodels.TextField()
+    enabled = dbmodels.BooleanField(default=False)
+
+    labels = dbmodels.ManyToManyField(tko_models.TestLabel, null=True,
+                                      db_table='planner_autoprocess_labels')
+    keyvals = dbmodels.ManyToManyField(KeyVal, null=True,
+                                       db_table='planner_autoprocess_keyvals')
+    bugs = dbmodels.ManyToManyField(Bug, null=True,
+                                    db_table='planner_autoprocess_bugs')
+    reason_override = dbmodels.CharField(max_length=255, null=True, blank=True)
+
+    class Meta:
+        db_table = 'planner_autoprocess'
+
+
+    def _get_details_unicode(self):
+        return 'Autoprocessing condition: %s' % self.condition
diff --git a/frontend/planner/models_test.py b/frontend/planner/models_test.py
new file mode 100644
index 0000000..cd95d39
--- /dev/null
+++ b/frontend/planner/models_test.py
@@ -0,0 +1,65 @@
+#!/usr/bin/python
+
+import unittest
+import common
+from autotest_lib.frontend import setup_django_environment
+from autotest_lib.frontend.afe import frontend_test_utils, rpc_utils
+from autotest_lib.frontend.planner import models
+
+
+class ModelWithHashTestBase(frontend_test_utils.FrontendTestMixin):
+    def setUp(self):
+        self._frontend_common_setup(fill_data=False)
+
+
+    def tearDown(self):
+        self._frontend_common_teardown()
+
+
+    def _model_class(self):
+        raise NotImplementedError('Subclasses must override _model_class()')
+
+
+    def _test_data(self):
+        raise NotImplementedError('Subclasses must override _test_data()')
+
+
+    def test_disallowed_operations(self):
+        def _call_create():
+            self._model_class().objects.create(**self._test_data())
+        self.assertRaises(Exception, _call_create)
+
+        model = self._model_class().objects.get_or_create(
+                **self._test_data())[0]
+        self.assertRaises(Exception, model.save)
+
+
+    def test_hash_field(self):
+        model = self._model_class().objects.get_or_create(
+                **self._test_data())[0]
+        self.assertNotEqual(model.id, None)
+        self.assertEqual(self._model_class()._compute_hash(**self._test_data()),
+                         model.the_hash)
+
+
+class ControlFileTest(ModelWithHashTestBase, unittest.TestCase):
+    def _model_class(self):
+        return models.ControlFile
+
+
+    def _test_data(self):
+        return {'contents' : 'test_control'}
+
+
+class KeyValTest(ModelWithHashTestBase, unittest.TestCase):
+    def _model_class(self):
+        return models.KeyVal
+
+
+    def _test_data(self):
+        return {'key' : 'test_key',
+                'value' : 'test_value'}
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/frontend/planner/rpc_interface.py b/frontend/planner/rpc_interface.py
new file mode 100644
index 0000000..b8ea992
--- /dev/null
+++ b/frontend/planner/rpc_interface.py
@@ -0,0 +1,8 @@
+"""\
+Functions to expose over the RPC interface.
+"""
+
+__author__ = 'jamesren@google.com (James Ren)'
+
+
+# Nothing yet
diff --git a/frontend/planner/urls.py b/frontend/planner/urls.py
new file mode 100644
index 0000000..6a3516b
--- /dev/null
+++ b/frontend/planner/urls.py
@@ -0,0 +1,12 @@
+from django.conf.urls.defaults import *
+import common
+from autotest_lib.frontend import settings, urls_common
+
+pattern_list, debug_pattern_list = (
+        urls_common.generate_pattern_lists('frontend.planner',
+                                           'TestPlannerClient'))
+
+if settings.DEBUG:
+    pattern_list += debug_pattern_list
+
+urlpatterns = patterns('', *pattern_list)
diff --git a/frontend/planner/views.py b/frontend/planner/views.py
new file mode 100644
index 0000000..092c56a
--- /dev/null
+++ b/frontend/planner/views.py
@@ -0,0 +1,24 @@
+import urllib2, sys, traceback, cgi
+
+import common
+from autotest_lib.frontend import views_common
+from autotest_lib.frontend.afe import rpc_handler
+from autotest_lib.frontend.planner import models, rpc_interface
+
+rpc_handler_obj = rpc_handler.RpcHandler((rpc_interface,),
+                                         document_module=rpc_interface)
+
+
+def handle_rpc(request):
+    return rpc_handler_obj.handle_rpc_request(request)
+
+
+def rpc_documentation(request):
+    return rpc_handler_obj.get_rpc_documentation()
+
+
+def model_documentation(request):
+    model_names = ('Plan', 'Host', 'ControlFile', 'Test', 'Job', 'KeyVal',
+                   'Bug', 'TestRun', 'DataType', 'History', 'SavedObject',
+                   'CustomQuery', 'AutoProcess')
+    return views_common.model_documentation(models, model_names)
diff --git a/frontend/settings.py b/frontend/settings.py
index 7001afc..43e5eb7 100644
--- a/frontend/settings.py
+++ b/frontend/settings.py
@@ -43,6 +43,7 @@
 # prefix applied to all URLs - useful if requests are coming through apache,
 # and you need this app to coexist with others
 URL_PREFIX = 'afe/server/'
+PLANNER_URL_PREFIX = 'planner/server/'
 
 # Local time zone for this installation. Choices can be found here:
 # http://www.postgresql.org/docs/8.1/static/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE
@@ -105,6 +106,7 @@
 
 INSTALLED_APPS = (
     'frontend.afe',
+    'frontend.planner',
     'django.contrib.admin',
     'django.contrib.auth',
     'django.contrib.contenttypes',
diff --git a/frontend/urls.py b/frontend/urls.py
index 74be84d..2f113b1 100644
--- a/frontend/urls.py
+++ b/frontend/urls.py
@@ -6,12 +6,14 @@
 admin.autodiscover()
 
 RE_PREFIX = '^' + settings.URL_PREFIX
+PLANNER_RE_PREFIX = '^' + settings.PLANNER_URL_PREFIX
 
 handler500 = 'frontend.afe.views.handler500'
 
 pattern_list = (
         (RE_PREFIX + r'admin/(.*)', admin.site.root),
         (RE_PREFIX, include('frontend.afe.urls')),
+        (PLANNER_RE_PREFIX, include('frontend.planner.urls')),
     )
 
 debug_pattern_list = (
diff --git a/frontend/urls_common.py b/frontend/urls_common.py
new file mode 100644
index 0000000..d9636ba
--- /dev/null
+++ b/frontend/urls_common.py
@@ -0,0 +1,36 @@
+import os
+
+
+def generate_pattern_lists(django_name, gwt_name):
+    """
+    Generates the common URL patterns for the given names
+
+    @param django_name the full name of the Django application
+                       (e.g., frontend.afe)
+    @param gwt_name the name of the GWT project (e.g., AfeClient)
+    @return the common standard and the debug pattern lists, as a tuple
+    """
+
+    pattern_list = [
+            (r'^(?:|noauth/)rpc/', '%s.views.handle_rpc' % django_name),
+            (r'^rpc_doc', '%s.views.rpc_documentation' % django_name)
+            ]
+
+    debug_pattern_list = [
+            (r'^model_doc/', '%s.views.model_documentation' % django_name),
+
+            # for GWT hosted mode
+            (r'^(?P<forward_addr>autotest.*)',
+             'autotest_lib.frontend.afe.views.gwt_forward'),
+
+            # for GWT compiled files
+            (r'^client/(?P<path>.*)$', 'django.views.static.serve',
+             {'document_root': os.path.join(os.path.dirname(__file__), '..',
+                                            'frontend', 'client', 'www')}),
+            # redirect / to compiled client
+            (r'^$', 'django.views.generic.simple.redirect_to',
+             {'url':
+              'client/autotest.%(name)s/%(name)s.html' % dict(name=gwt_name)}),
+            ]
+
+    return (pattern_list, debug_pattern_list)
diff --git a/frontend/views_common.py b/frontend/views_common.py
new file mode 100644
index 0000000..14ac1e3
--- /dev/null
+++ b/frontend/views_common.py
@@ -0,0 +1,9 @@
+from django.http import HttpResponse
+
+def model_documentation(models_module, model_names):
+    doc = '<h2>Models</h2>\n'
+    for model_name in model_names:
+        model_class = getattr(models_module, model_name)
+        doc += '<h3>%s</h3>\n' % model_name
+        doc += '<pre>\n%s</pre>\n' % model_class.__doc__
+    return HttpResponse(doc)
diff --git a/new_tko/tko/urls.py b/new_tko/tko/urls.py
index 4c4d10e..fc64e58 100644
--- a/new_tko/tko/urls.py
+++ b/new_tko/tko/urls.py
@@ -1,30 +1,15 @@
 from django.conf.urls.defaults import *
-from django.conf import settings
-import os
+import common
+from autotest_lib.frontend import settings, urls_common
 
-pattern_list = [(r'^(?:|noauth/)rpc/', 'new_tko.tko.views.handle_rpc'),
-                (r'^(?:|noauth/)jsonp_rpc/',
-                 'new_tko.tko.views.handle_jsonp_rpc'),
-                (r'^(?:|noauth/)csv/', 'new_tko.tko.views.handle_csv'),
-                (r'^rpc_doc', 'new_tko.tko.views.rpc_documentation'),
-                (r'^(?:|noauth/)plot/', 'new_tko.tko.views.handle_plot')]
+pattern_list, debug_pattern_list = (
+        urls_common.generate_pattern_lists(django_name='new_tko.tko',
+                                           gwt_name='TkoClient'))
 
-debug_pattern_list = [
-    (r'^model_doc/', 'new_tko.tko.views.model_documentation'),
-
-    # for GWT hosted mode
-    (r'^(?P<forward_addr>autotest.*)',
-     'autotest_lib.frontend.afe.views.gwt_forward'),
-
-    # for GWT compiled files
-    (r'^client/(?P<path>.*)$', 'django.views.static.serve',
-     {'document_root': os.path.join(os.path.dirname(__file__), '..', '..',
-                                    'frontend', 'client', 'www')}),
-    # redirect / to compiled client
-    (r'^$', 'django.views.generic.simple.redirect_to',
-     {'url': 'client/autotest.TkoClient/TkoClient.html'}),
-
-]
+pattern_list += [(r'^(?:|noauth/)jsonp_rpc/',
+                  'new_tko.tko.views.handle_jsonp_rpc'),
+                 (r'^(?:|noauth/)csv/', 'new_tko.tko.views.handle_csv'),
+                 (r'^(?:|noauth/)plot/', 'new_tko.tko.views.handle_plot')]
 
 if settings.DEBUG:
     pattern_list += debug_pattern_list