Merge branch 'list-columns'
diff --git a/app/soc/modules/gci/templates/student_list.py b/app/soc/modules/gci/templates/student_list.py
index 156e703..5ec0c24 100644
--- a/app/soc/modules/gci/templates/student_list.py
+++ b/app/soc/modules/gci/templates/student_list.py
@@ -96,8 +96,8 @@
         (lambda e, sp, *args: sp[e.parent_key()].student_info.expected_graduation),
         hidden=True)
 
-    list_config.addColumn('completed_tasks', 'Completed tasks',
-        lambda e, *args: e.number_of_completed_tasks, escape=False)
+    list_config.addNumericalColumn('completed_tasks', 'Completed tasks',
+        lambda e, *args: e.number_of_completed_tasks)
 
     def formsSubmitted(e, sp, form):
       """Returns "Yes" if form has been submitted otherwise "No".
diff --git a/app/soc/modules/gci/views/dashboard.py b/app/soc/modules/gci/views/dashboard.py
index 1005d83..12ae0c5 100644
--- a/app/soc/modules/gci/views/dashboard.py
+++ b/app/soc/modules/gci/views/dashboard.py
@@ -654,7 +654,7 @@
     list_config.addSimpleColumn('status', 'Status')
 
     # TODO (madhu): Super temporary solution until the pretty lists are up.
-    list_config.addColumn('edit', 'Edit',
+    list_config.addHtmlColumn('edit', 'Edit',
         lambda entity, *args: (
           '<a href="%s" style="color:#0000ff;text-decoration:underline;">'
           'Edit</a>' % (data.redirect.id(entity.key().id()).urlOf(
diff --git a/app/soc/modules/gci/views/org_score.py b/app/soc/modules/gci/views/org_score.py
index 73061a0..7336f6a 100644
--- a/app/soc/modules/gci/views/org_score.py
+++ b/app/soc/modules/gci/views/org_score.py
@@ -43,7 +43,7 @@
         ent.parent().key().id_or_name())), hidden=True)
     list_config.addColumn('student', 'Student',
         lambda e, *args: e.parent().name())
-    list_config.addColumn('tasks', 'Tasks',
+    list_config.addNumericalColumn('tasks', 'Tasks',
         lambda e, *args: e.numberOfTasks())
     list_config.setDefaultSort('tasks', 'desc')
 
diff --git a/app/soc/views/helper/lists.py b/app/soc/views/helper/lists.py
index 4846ac0..754d026 100644
--- a/app/soc/views/helper/lists.py
+++ b/app/soc/views/helper/lists.py
@@ -28,6 +28,111 @@
 from soc.views.template import Template
 
 
+class ColumnType(object):
+  # TODO(daniel): add doc string
+  """
+  """
+  PLAIN_TEXT = 'plain_text'
+  NUMERICAL = 'numerical'
+  HTML = 'html'
+
+  def safe(self, value):
+    """Returns a safe representation of the specified value which can be safely
+    rendered as HTML in the list.
+
+    This method should be overridden by all non-abstract subclasses.
+
+    Args:
+      value: the specified value for which to return the safe representation
+    """
+    raise NotImplementedError
+
+
+class PlainTextColumnType(ColumnType):
+  """Class which represents a column which contains textual values.
+
+  As it may hold arbitrary string of bytes, the returned value must be
+  HTML escaped.
+  """
+
+  def safe(self, value):
+    """Returns HTML escaped representation of the specified value.
+
+    Args:
+      value: the specified value which is to be HTML escaped
+    """
+    return html.conditional_escape(value)
+
+
+class NumericalColumnType(ColumnType):
+  """Class which represents a column which contains numerical values."""
+
+  def safe(self, value):
+    """Returns the safe representation of the specified value. It is assumed
+    that only numerical values are passed here, so the the output is not HTML
+    escaped.
+
+    Args:
+      value: the specified string or a number for which 
+          to return the safe representation
+
+    Returns:
+      numerical representation of the specified value or the empty string for
+      None and the empty string.
+
+    Raises:
+      ValueError: if the specified value is invalid
+      TypeError: if the specified value is neither a number nor a string
+    """
+    if value is None or value == '':
+      safe_value = ''
+    elif isinstance(value, int) or isinstance(value, long) or \
+        isinstance(value, float):
+      safe_value = value
+    else:
+      try:
+        safe_value = int(value)
+      except ValueError:
+        safe_value = float(value)
+
+    return safe_value
+
+
+class HtmlColumnType(ColumnType):
+  """Class which represents a column which contains HTML content."""
+
+  def safe(self, value):
+    """Returns the safe representation of the specified value. The output is
+    not HTML escaped so it is developer's responsibility to assure it does
+    not contain any malicious content.
+
+    Args:
+      value: the specified value for which to return the safe representation
+    """
+    return value
+
+
+class ColumnTypeFactory(object):
+  """Parametric factory which creates concrete instances of."""
+
+  @classmethod
+  def create(cls, column_type):
+    """Returns an instance of the subclass of ColumnType class which
+    corresponds to the specified column_type parameter.
+
+    Args:
+      column_type: the specified column type which must be one of the constant
+          values specified in ColumnType class.
+    """
+    if column_type == ColumnType.PLAIN_TEXT:
+      return PlainTextColumnType()
+    elif column_type == ColumnType.NUMERICAL:
+      return NumericalColumnType()
+    elif column_type == ColumnType.HTML:
+      return HtmlColumnType()
+    else:
+      raise ValueError("Invalid column_type: %s" % column_type)
+
 def getListIndex(request):
   """Returns the index of the requested list.
   """
@@ -126,7 +231,8 @@
       self.row_list = row_list
 
   def addColumn(self, col_id, name, func, width=None, resizable=True,
-                hidden=False, searchhidden=True, options=None, escape=True):
+                hidden=False, searchhidden=True, options=None,
+                column_type=ColumnType.PLAIN_TEXT):
     """Adds a column to the end of the list.
 
     Args:
@@ -142,7 +248,7 @@
       hidden: Whether the column should be hidden by default.
       searchhidden: Whether this column should be searchable when hidden.
       options: An array of (regexp, display_value) tuples.
-      escape: Whether the value should be HTML escaped.
+      column_type: One of the types specified in ColumnType class.
     """
     if self._col_functions.get(col_id):
       logging.warning('Column with id %s is already defined' % col_id)
@@ -155,7 +261,7 @@
         'index': col_id,
         'resizable': resizable,
         'hidden': hidden,
-        'escape': escape,
+        'column_type': column_type,
     }
 
     if width:
@@ -177,6 +283,25 @@
     self._col_names.append(name)
     self._col_functions[col_id] = func
 
+  def addNumericalColumn(self, col_id, name, func, **kwargs):
+    """Adds a numerical column to the end of the list.
+
+    It is expected that all the values in this columns will be numbers.
+    The rendered output will not be HTML escaped.
+    """
+    self.addColumn(
+        col_id, name, func, column_type=ColumnType.NUMERICAL, **kwargs)
+
+  def addHtmlColumn(self, col_id, name, func, **kwargs):
+    """Adds a HTML column to the end of the list.
+
+    The content of the column may contain arbitrary HTML code which will be
+    rendered on the page without being escaped. It is vulnerable to malicious
+    inputs, so it should never be used for values which are entered by users.
+    """
+    self.addColumn(
+        col_id, name, func, column_type=ColumnType.HTML, **kwargs)
+
   def __addRowButton(self, col_id, button_id, caption, type, classes,
                      parameters):
     """Internal method for adding row buttons so that the uniqueness of
@@ -745,10 +870,9 @@
     columns = {}
     for col_id, func in self._config._col_functions.iteritems():
       col_model = self._config._col_map.get(col_id, {})
-      escape = col_model.get('escape', True)
-      val = func(entity, *args, **kwargs)
-      val_str = val if val != None else ''
-      columns[col_id] = html.conditional_escape(val) if escape else val_str
+      value = func(entity, *args, **kwargs) or ''
+      column_type = ColumnTypeFactory.create(col_model['column_type'])
+      columns[col_id] = column_type.safe(value)
 
     row = {}
     buttons= {}
diff --git a/tests/app/soc/views/helper/test_lists.py b/tests/app/soc/views/helper/test_lists.py
new file mode 100644
index 0000000..09cf6a6
--- /dev/null
+++ b/tests/app/soc/views/helper/test_lists.py
@@ -0,0 +1,162 @@
+# Copyright 2013 the Melange authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+"""Tests for lists helper functions."""
+
+import math
+import sys
+import unittest
+
+from django.utils import html
+
+from soc.views.helper import lists
+
+
+class ColumnTypeFactoryTest(unittest.TestCase):
+  """Unit tests for ColumnTypeFactory class."""
+
+  def testCreatePlainText(self):
+    column_type = lists.ColumnTypeFactory.create(lists.ColumnType.PLAIN_TEXT)
+    self.assertIsInstance(column_type, lists.PlainTextColumnType)
+
+  def testCreateNumerical(self):
+    column_type = lists.ColumnTypeFactory.create(lists.ColumnType.NUMERICAL)
+    self.assertIsInstance(column_type, lists.NumericalColumnType)
+
+  def testCreateHtml(self):
+    column_type = lists.ColumnTypeFactory.create(lists.ColumnType.HTML)
+    self.assertIsInstance(column_type, lists.HtmlColumnType)
+
+  def testCreateWithInvalidArgument(self):
+    with self.assertRaises(ValueError):
+      column_type = lists.ColumnTypeFactory.create(None)
+
+    with self.assertRaises(ValueError):
+      column_type = lists.ColumnTypeFactory.create('invalid_column_type')
+
+
+class NumericalColumnTypeTest(unittest.TestCase):
+  """Unit tests for NumericalColumnType class."""
+
+  def setUp(self):
+    self.column_type = lists.NumericalColumnType()
+
+  def testSafeForInt(self):
+    self.assertEqual(0, self.column_type.safe(0))
+    self.assertEqual(1, self.column_type.safe(1))
+    self.assertEqual(-1, self.column_type.safe(-1))
+    self.assertEqual(42, self.column_type.safe(42))
+    self.assertEqual(-42, self.column_type.safe(-42))
+    self.assertEqual(sys.maxint, self.column_type.safe(sys.maxint))
+
+  def testSafeForLong(self):
+    self.assertEqual(0, self.column_type.safe(0L))
+    self.assertEqual(1, self.column_type.safe(1L))
+    self.assertEqual(-1, self.column_type.safe(-1L))
+    self.assertEqual(42, self.column_type.safe(42L))
+    self.assertEqual(-42, self.column_type.safe(-42L))
+    self.assertEqual(10**30, self.column_type.safe(10**30))
+    self.assertEqual(-10**30, self.column_type.safe(-10**30))
+
+  def testSafeForFloat(self):
+    self.assertEqual(0.0, self.column_type.safe(0.0))
+    self.assertEqual(1.0, self.column_type.safe(1.0))
+    self.assertEqual(-1.0, self.column_type.safe(-1.0))
+    self.assertEqual(math.pi, self.column_type.safe(math.pi))
+    self.assertEqual(-math.pi, self.column_type.safe(-math.pi))
+
+  def testSafeForValidString(self):
+    self.assertEqual('', self.column_type.safe(''))
+
+    self.assertEqual(0, self.column_type.safe('0'))
+    self.assertEqual(0, self.column_type.safe('0.0'))
+    self.assertEqual(0, self.column_type.safe('-0.0'))
+    self.assertEqual(0, self.column_type.safe('+0.0'))
+    self.assertEqual(0, self.column_type.safe('.0'))
+
+    self.assertEqual(1, self.column_type.safe('1'))
+    self.assertEqual(1, self.column_type.safe('1.0'))
+    self.assertEqual(1, self.column_type.safe('+1.0'))
+    self.assertEqual(1, self.column_type.safe('1.000000'))
+
+    self.assertEqual(1.1, self.column_type.safe('1.1'))
+    self.assertEqual(3.14159265359, self.column_type.safe('3.14159265359'))
+    self.assertEqual(-7.12345, self.column_type.safe('-00007.12345'))
+    self.assertEqual(0.002, self.column_type.safe('2e-3'))
+    self.assertEqual(50, self.column_type.safe('  50  '))
+
+
+  def testSafeForInvalidString(self):
+    with self.assertRaises(ValueError):
+      self.column_type.safe('a')
+
+    with self.assertRaises(ValueError):
+      self.column_type.safe('1.0.0')
+
+    with self.assertRaises(ValueError):
+      self.column_type.safe('1L')
+
+    with self.assertRaises(ValueError):
+      self.column_type.safe('2e-3 a')
+
+  def testSafeForInvalidType(self):
+    with self.assertRaises(TypeError):
+      self.column_type.safe(object())
+
+    with self.assertRaises(TypeError):
+      self.column_type.safe([1])
+
+
+class PlainTextColumnTypeTest(unittest.TestCase):
+  """Unit tests for PlainTextColumnType class."""
+
+  def setUp(self):
+    self.column_type = lists.PlainTextColumnType()
+
+  def _escaped(self, value):
+    return html.conditional_escape(value)
+
+  def testSafe(self):
+    text = ''
+    self.assertEqual(text, self.column_type.safe(text))
+
+    text = 'some example text'
+    self.assertEqual(text, self.column_type.safe(text))
+
+    text = '<a href="www.example.com">Example</a>'
+    self.assertEqual(self._escaped(text), self.column_type.safe(text))
+
+    text = '<script>alert("hacked")</script>'
+    self.assertEqual(self._escaped(text), self.column_type.safe(text))
+
+
+class HtmlColumnTypeTest(unittest.TestCase):
+  """Unit tests for HtmlTextColumnType class."""
+
+  def setUp(self):
+    self.column_type = lists.HtmlColumnType()
+
+  def testSafe(self):
+    text = ''
+    self.assertEqual(text, self.column_type.safe(text))
+
+    text = 'some example text'
+    self.assertEqual(text, self.column_type.safe(text))
+
+    text = '<a href="www.example.com">Example</a>'
+    self.assertEqual(text, self.column_type.safe(text))
+
+    text = '<script>alert("hacked")</script>'
+    self.assertEqual(text, self.column_type.safe(text))