Merge branch 'daniel/columns'
diff --git a/app/melange/models/organization.py b/app/melange/models/organization.py
index 313c471..d695f7c 100644
--- a/app/melange/models/organization.py
+++ b/app/melange/models/organization.py
@@ -135,6 +135,11 @@
   #: Main license that is used by the organization.
   license = ndb.StringProperty(choices=licenses.LICENSES)
 
+  #: Additional fields for proposals that are sent to this organization.
+  #: They can be defined by organization administrators and are displayed
+  #: as extra columns in the list of all proposals for the organization.
+  extra_fields = ndb.StringProperty(repeated=True)
+
 
 class OrganizationMessages(ndb.Model):
   """Model that represents various messages defined by the organization.
diff --git a/app/melange/utils/countries.py b/app/melange/utils/countries.py
index 56c6f3b..585daa9 100644
--- a/app/melange/utils/countries.py
+++ b/app/melange/utils/countries.py
@@ -18,7 +18,7 @@
 original list. Also missing are the following US OFAC embargoed and
 Commerce Department export-controlled countries:
 
-  Cuba, Iran, Myanmar (formerly Burma), North Korea, Sudan, Syria
+  Cuba, Iran, North Korea, Sudan, Syria
 """
 
 
@@ -52,6 +52,7 @@
     'Bosnia and Herzegovina': ('.ba', 'Europe'),
     'Botswana': ('.bw', 'Africa'),
     'Bouvet Island': ('.bv', 'Europe'),
+    'Burma': ('.mm', 'Asia'),
     'Brazil': ('.br', 'South America'),
     'British Indian Ocean Territory': ('.io', 'Asia'),
     'Brunei Darussalam': ('.bn', 'Asia'),
diff --git a/app/soc/logic/cleaning.py b/app/soc/logic/cleaning.py
index 38c8f10..1d93db3 100644
--- a/app/soc/logic/cleaning.py
+++ b/app/soc/logic/cleaning.py
@@ -315,11 +315,11 @@
     value_length = len(value)
 
     if value_length < min_length:
-      raise forms.ValidationError(DEF_MUST_BE_ABOVE_LIMIT %(
+      raise forms.ValidationError(DEF_MUST_BE_ABOVE_LIMIT % (
           min_length, value_length))
 
     if value_length > max_length:
-      raise forms.ValidationError(DEF_MUST_BE_UNDER_LIMIT %(
+      raise forms.ValidationError(DEF_MUST_BE_UNDER_LIMIT % (
           max_length, value_length))
 
     return value
diff --git a/app/soc/modules/gci/views/profile.py b/app/soc/modules/gci/views/profile.py
index eed0213..edab57b 100644
--- a/app/soc/modules/gci/views/profile.py
+++ b/app/soc/modules/gci/views/profile.py
@@ -92,9 +92,9 @@
 
   # Most of these countries are not even in the countries model, but
   # we still list them anyway.
-  GCI_UNALLOWED_COUNTRIES = ['Cuba', 'Iran', 'Syria', 'North Korea', 'Sudan',
-                             'Myanmar (Burma)', 'Brazil', 'Saudi Arabia',
-                             'Italy']
+  GCI_UNALLOWED_COUNTRIES = [
+      'Cuba', 'Iran', 'Syria', 'North Korea', 'Sudan', 'Brazil',
+      'Saudi Arabia', 'Italy']
 
   NOT_OPEN_TO_COUNTRY_MSG = ugettext(
       'This contest is not open to residents of this country.')
@@ -310,7 +310,7 @@
     """
     if 'delete_account' in data.POST:
       return self.deleteAccountPostAction(data)
-    else: # regular POST request
+    else:  # regular POST request
       return self.editProfilePostAction(data, check, mutator)
 
   def deleteAccountPostAction(self, data):
diff --git a/app/summerofcode/logic/proposal.py b/app/summerofcode/logic/proposal.py
index b37140a..a40b0ec 100644
--- a/app/summerofcode/logic/proposal.py
+++ b/app/summerofcode/logic/proposal.py
@@ -624,6 +624,7 @@
     proposal_key: ndb.Key of the proposal.
     mentors: List of profile_model.Profile entities of the mentors to
       assign for the proposal.
+
   Returns:
     rich_bool.RichBool whose value is set to True, if the specified proposal
     is assigned the specified mentors. Otherwise, rich_bool.RichBool
@@ -642,3 +643,32 @@
     proposal.put()
     return rich_bool.TRUE
 
+
+@ndb.transactional
+def setExtraData(proposal_key, organization, extra_data):
+  """Sets extra data for the specified proposal.
+
+  Args:
+    proposal_key: ndb.Key of the proposal.
+    organization: org_model.Organization entity to which
+      the proposal is submitted.
+    extra_data: A dict mapping extra fields with the corresponding values.
+  """
+  proposal = proposal_key.get()
+
+  if proposal.organization != organization.key:
+    raise ValueError(
+        'Organization %s does not correspond to proposal %s' % (
+            organization.key.id(), proposal.organization.id()))
+  else:
+    # filter out invalid extra data fields
+    extra_data = dict(
+        (k, v) for k, v in extra_data.iteritems()
+        if k in organization.extra_fields)
+
+    proposal.extra_data = proposal.extra_data or {}
+    for key, value in extra_data.iteritems():
+      proposal.extra_data[key] = value
+
+    proposal.put()
+
diff --git a/app/summerofcode/models/proposal.py b/app/summerofcode/models/proposal.py
index 474cb7c..56b5c94 100644
--- a/app/summerofcode/models/proposal.py
+++ b/app/summerofcode/models/proposal.py
@@ -130,6 +130,10 @@
   #: Field storing the date when the proposal was modified for the last time.
   modified_on = ndb.DateTimeProperty(required=True, auto_now=True)
 
+  #: Values for additional fields (defined by the organization) for
+  #: this proposal.
+  extra_data = ndb.JsonProperty()
+
   @property
   def count_scores(self):
     """Returns number of scores for this proposal."""
diff --git a/app/summerofcode/templates/proposal_list.py b/app/summerofcode/templates/proposal_list.py
index b490b28..4bf9e31 100644
--- a/app/summerofcode/templates/proposal_list.py
+++ b/app/summerofcode/templates/proposal_list.py
@@ -14,6 +14,8 @@
 
 """Module for templates with proposal lists."""
 
+import cgi
+
 from google.appengine.ext import ndb
 
 from django.utils import translation
@@ -76,6 +78,9 @@
         _ACCEPTED_BUT_NO_MENTOR_ASSIGNED_STATUS,
         _ACCEPTED_BUT_IGNORED_STATUS]))
 
+SAVE_BUTTON_ID = 'save'
+_SAVE_BUTTON_LABEL = 'Save'
+
 
 class ProposalList(template.Template):
   """Template for list of proposals."""
@@ -187,7 +192,12 @@
   Returns:
     The newly created ProposalList object.
   """
-  list_config = lists.ListConfiguration()
+  list_config = lists.ListConfiguration(add_key_column=False)
+
+  list_config.addPlainTextColumn(
+      'key', 'Key',
+      lambda entity, *args: entity.key.urlsafe(),
+      hidden=True)
   list_config.addSimpleColumn('title', 'Title')
   list_config.addPlainTextColumn(
       'student', 'Student',
@@ -257,7 +267,25 @@
           entity.key.parent(), entity.key.id(),
           urls.UrlNames.PROPOSAL_REVIEW_AS_ORG_MEMBER))
 
-  # TODO(daniel): add extra columns
+  def getExtraColumnFunc(column_id):
+    """Helper function to get a function to get value for an extra column."""
+    def func(entity, *args):
+      return entity.extra_data.get(column_id, '') if entity.extra_data else ''
+    return func
+
+  extra_columns = []
+  orgs = ndb.get_multi(profile.mentor_for)
+  for org in orgs:
+    for column_id in org.extra_fields:
+      extra_columns.append(column_id)
+
+      list_config.addPlainTextColumn(
+          column_id, cgi.escape(column_id), getExtraColumnFunc(column_id))
+      list_config.setColumnEditable(column_id, True, 'text', {})
+
+  if extra_columns:
+    list_config.addPostEditButton(
+        SAVE_BUTTON_ID, _SAVE_BUTTON_LABEL, '', refresh='none')
 
   query = proposal_logic.queryProposalsForOrganizationMember(profile)
 
diff --git a/app/summerofcode/views/org_app.py b/app/summerofcode/views/org_app.py
index 7d5e0f9..3dc60b8 100644
--- a/app/summerofcode/views/org_app.py
+++ b/app/summerofcode/views/org_app.py
@@ -158,6 +158,13 @@
     'Custom message that is sent to all rejected students who submitted a '
     'proposal to this organization.')
 
+EXTRA_FIELDS_HELP_TEXT = translation.ugettext(
+    'Additional fields that will be added to proposals for this '
+    'organization. These fields are displayed as columns in the list of '
+    'proposals and may be edited by organization members. Field names should '
+    'appear on lines by themselves. Additionally, each name must be shorter '
+    'than %s characters')
+
 ORG_ID_LABEL = translation.ugettext('Organization ID')
 
 ORG_NAME_LABEL = translation.ugettext('Organization name')
@@ -208,11 +215,14 @@
 REJECTED_STUDENTS_MESSAGE_LABEL = translation.ugettext(
     'Message to rejected students')
 
+EXTRA_FIELDS_LABEL = translation.ugettext(
+    'Proposal extra fields')
+
 # TODO(daniel): list of countries should be program-specific
 ELIGIBLE_COUNTRY_LABEL = translation.ugettext(
     'I hereby declare that the applying organization is not located in '
     'any of the countries which are not eligible to participate in the '
-    'program: Iran, Syria, Cuba, Sudan, North Korea and Myanmar (Burma).')
+    'program: Iran, Syria, Cuba, Sudan, North Korea.')
 
 ORG_APPLICATION_SUBMIT_PAGE_NAME = translation.ugettext(
     'Submit application')
@@ -242,6 +252,10 @@
     'The currently logged in profile cannot be specified as '
     'the other organization administrator.')
 
+EXTRA_FIELD_TOO_LONG = translation.ugettext(
+    'Extra field %s is too long: %s. Please make sure that all names are '
+    'shorter than %s characters.')
+
 TAG_TOO_LONG = translation.ugettext('Tag %s is too long: %s')
 
 GENERAL_INFO_GROUP_TITLE = translation.ugettext('General Info')
@@ -255,7 +269,7 @@
 
 _ORG_PREFERENCES_PROPERTIES_FORM_KEYS = [
     'max_score', 'slot_request_max', 'slot_request_min', 'contrib_template',
-    'scoring_enabled']
+    'scoring_enabled', 'extra_fields']
 
 _ORG_PROFILE_PROPERTIES_FORM_KEYS = [
     'description', 'ideas_page', 'logo_url', 'name', 'org_id', 'tags',
@@ -264,6 +278,7 @@
 _ORG_MESSAGES_PROPERTIES_FORM_KEYS = [
     'accepted_students_message', 'rejected_students_message']
 
+EXTRA_FIELD_MAX_LENGTH = 25
 TAG_MAX_LENGTH = 30
 MAX_SCORE_MIN_VALUE = 1
 MAX_SCORE_MAX_VALUE = 12
@@ -337,6 +352,29 @@
   return tag_list
 
 
+def cleanExtraFields(text):
+  """Cleans extra_fields field.
+
+  Args:
+    text: The submitted value which is an arbitrary string.
+
+  Returns:
+    A list of extra fields.
+
+  Raises:
+    django_forms.ValidationError if at least one of the fields is not valid.
+  """
+  extra_fields = []
+  for field in [field for field in text.split('\n') if field]:
+    field = field.strip()
+    if len(field) > EXTRA_FIELD_MAX_LENGTH:
+      raise django_forms.ValidationError(
+          EXTRA_FIELD_TOO_LONG % (field, len(field), EXTRA_FIELD_MAX_LENGTH))
+    else:
+      extra_fields.append(field)
+  return extra_fields
+
+
 class _OrgProfileForm(gsoc_forms.GSoCModelForm):
   """Form to set properties of organization profile by organization
   administrators.
@@ -535,8 +573,23 @@
       widget=django_forms.Textarea, label=REJECTED_STUDENTS_MESSAGE_LABEL,
       help_text=REJECTED_STUDENTS_MESSAGE_HELP_TEXT, required=False)
 
+  extra_fields = django_forms.CharField(
+      widget=django_forms.Textarea, label=EXTRA_FIELDS_LABEL,
+      help_text=EXTRA_FIELDS_HELP_TEXT % EXTRA_FIELD_MAX_LENGTH, required=False)
+
   Meta = object
 
+  def clean_extra_fields(self):
+    """Cleans extra_fields field.
+
+    Returns:
+      A list of submitted extra fields.
+
+    Raises:
+      django_forms.ValidationError if at least one of the fields is not valid.
+    """
+    return cleanExtraFields(self.cleaned_data['extra_fields'])
+
   def getOrgProperties(self):
     """Returns properties of the organization that were submitted in this form.
 
@@ -706,6 +759,26 @@
         return http.HttpResponseRedirect(url)
 
 
+def _adaptOrgPreferencesPropertiesForForm(properties):
+  """Adapts properties of an organization and organization messages entities,
+  which are persisted in datastore, to representation which may be passed
+  to populate _OrgPreferencesForm.
+
+  Args:
+    properties: A dict containing contact properties as persisted
+      in datastore.
+
+  Returns:
+    A dict mapping properties of organization models to values which can be
+    populated to an organization preferences form.
+  """
+  if org_model.Organization.extra_fields._name in properties:
+    properties[org_model.Organization.extra_fields._name] = '\n'.join(
+        field for field
+        in properties[org_model.Organization.extra_fields._name])
+  return properties
+
+
 ORG_PREFERENCES_EDIT_PAGE_ACCESS_CHECKER = access.ConjuctionAccessChecker([
     access.IS_USER_ORG_ADMIN_FOR_NDB_ORG,
     access.UrlOrgStatusAccessChecker([org_model.Status.ACCEPTED])])
@@ -728,9 +801,10 @@
 
   def context(self, data, check, mutator):
     """See base.RequestHandler.context for specification."""
-    form_data = data.url_ndb_org.to_dict()
-    form_data.update(
+    properties = data.url_ndb_org.to_dict()
+    properties.update(
         org_logic.getOrganizationMessages(data.url_ndb_org.key).to_dict())
+    form_data = _adaptOrgPreferencesPropertiesForForm(properties)
 
     form = _OrgPreferencesForm(data=data.POST or form_data)
     return {
diff --git a/app/summerofcode/views/proposal.py b/app/summerofcode/views/proposal.py
index 48a8dc7..4d5994d 100644
--- a/app/summerofcode/views/proposal.py
+++ b/app/summerofcode/views/proposal.py
@@ -14,6 +14,8 @@
 
 """Module containing the proposal related views for Summer Of Code."""
 
+import json
+
 from google.appengine.ext import ndb
 
 from django import forms as django_forms
@@ -630,6 +632,31 @@
     else:
       raise exception.BadRequest(message='This data cannot be accessed.')
 
+  def post(self, data, check, mutator):
+    """See base.RequestHandler.post for specification."""
+    json_data = data.POST.get('data')
+    if json_data:
+      parsed_data = json.loads(json_data)
+
+      button_id = data.POST.get('button_id')
+      if not button_id:
+        raise exception.BadRequest(message='Missing button ID')
+      elif button_id == proposal_list.SAVE_BUTTON_ID:
+        # the first and the only key is string representation of proposal key
+        if len(parsed_data.keys()) != 1:
+          raise exception.BadRequest(message='Invalid data format.')
+        else:
+          urlsafe_key = parsed_data.keys()[0]
+          proposal_key = ndb.Key(urlsafe=urlsafe_key)
+          proposal = proposal_key.get()
+          if not proposal:
+            raise exception.BadRequest(message='Proposal does not exist.')
+          else:
+            proposal_logic.setExtraData(
+                proposal.key, proposal.organization.get(),
+                parsed_data[urlsafe_key])
+    else:
+      raise exception.BadRequest(message='Missing data')
 
 LIST_PROPOSALS_FOR_ORG_MEMBER = ListProposalsForOrgMember(
     gsoc_base._GSOC_INITIALIZER, links.SOC_LINKER, render.SOC_RENDERER,
diff --git a/tests/app/summerofcode/logic/test_proposal.py b/tests/app/summerofcode/logic/test_proposal.py
index f6ad40e..c69f705 100644
--- a/tests/app/summerofcode/logic/test_proposal.py
+++ b/tests/app/summerofcode/logic/test_proposal.py
@@ -960,3 +960,62 @@
     result = proposal_logic.assignMentors(self.proposal.key, mentors)
     self.assertFalse(result)
     self.assertEqual(result.extra[0].key, illegal.key)
+
+
+_TEST_PROPOSAL_FIELD_ONE_ID = 'test field'
+_TEST_PROPOSAL_FIELD_ONE_VALUE = 'test value'
+_TEST_PROPOSAL_FIELD_ONE_OTHER_VALUE = 'test other value'
+_TEST_PROPOSAL_FIELD_TWO_ID = 'other field'
+_TEST_PROPOSAL_FIELD_TWO_VALUE = 'other value'
+_TEST_NON_EXISTING_FIELD_ID = 'non-existing field'
+_TEST_NON_EXISTING_FIELD_VALUE = 'unused value'
+
+_TEST_EXTRA_DATA = {
+    _TEST_PROPOSAL_FIELD_ONE_ID: _TEST_PROPOSAL_FIELD_ONE_VALUE,
+    _TEST_PROPOSAL_FIELD_TWO_ID: _TEST_PROPOSAL_FIELD_TWO_VALUE,
+    _TEST_NON_EXISTING_FIELD_ID: _TEST_NON_EXISTING_FIELD_VALUE,
+    }
+
+class SetExtraDataTest(unittest.TestCase):
+  """Unit tests for setExtraData function."""
+
+  def setUp(self):
+    """See unittest.TestCase.setUp for specification."""
+    self.program = program_utils.seedGSoCProgram()
+    self.org = org_utils.seedOrganization(
+        self.program.key(), extra_fields=[
+            _TEST_PROPOSAL_FIELD_ONE_ID, _TEST_PROPOSAL_FIELD_TWO_ID])
+    student = profile_utils.seedNDBStudent(self.program)
+    self.proposal = proposal_utils.seedNDBProposal(
+        student.key, self.program.key(), org_key=self.org.key)
+
+  def testExtraDataSet(self):
+    """Tests that extra data is set correctly."""
+    proposal_logic.setExtraData(self.proposal.key, self.org, _TEST_EXTRA_DATA)
+    proposal = self.proposal.key.get()
+
+    # check that fields are set
+    self.assertEqual(
+        proposal.extra_data[_TEST_PROPOSAL_FIELD_ONE_ID],
+        _TEST_PROPOSAL_FIELD_ONE_VALUE)
+    self.assertEqual(
+        proposal.extra_data[_TEST_PROPOSAL_FIELD_TWO_ID],
+        _TEST_PROPOSAL_FIELD_TWO_VALUE)
+
+    # check that the non-existing field is not set
+    self.assertNotIn(_TEST_NON_EXISTING_FIELD_ID, proposal.extra_data)
+
+  def testExtraDataUpdated(self):
+    """Tests that extra data is updated correctly if already exists."""
+    self.proposal.extra_data = {
+        _TEST_PROPOSAL_FIELD_ONE_ID: _TEST_PROPOSAL_FIELD_ONE_OTHER_VALUE
+        }
+    self.proposal.put()
+
+    proposal_logic.setExtraData(self.proposal.key, self.org, _TEST_EXTRA_DATA)
+    proposal = self.proposal.key.get()
+
+    # check that the field is updated
+    self.assertEqual(
+        proposal.extra_data[_TEST_PROPOSAL_FIELD_ONE_ID],
+        _TEST_PROPOSAL_FIELD_ONE_VALUE)
diff --git a/tests/app/summerofcode/views/test_org_app.py b/tests/app/summerofcode/views/test_org_app.py
index c587d99..40d0d92 100644
--- a/tests/app/summerofcode/views/test_org_app.py
+++ b/tests/app/summerofcode/views/test_org_app.py
@@ -14,6 +14,9 @@
 
 """Unit tests for organization application view."""
 
+import unittest
+
+from django import forms as django_forms
 from django import http
 
 from google.appengine.ext import ndb
@@ -655,6 +658,7 @@
 TEST_SCORING_ENABLED = False
 TEST_ACCEPTED_STUDENTS_MESSAGE = u'Test Accepted Students Message'
 TEST_REJECTED_STUDENTS_MESSAGE = u'Test Rejected Students Message'
+TEST_EXTRA_FIELDS = ['one field', 'second field', 'third field']
 
 class OrgPreferencesEditPageTest(test_utils.GSoCDjangoTestCase):
   """Unit tests for OrgPreferencesEditPage class."""
@@ -691,6 +695,7 @@
         'scoring_enabled': TEST_SCORING_ENABLED,
         'accepted_students_message': TEST_ACCEPTED_STUDENTS_MESSAGE,
         'rejected_students_message': TEST_REJECTED_STUDENTS_MESSAGE,
+        'extra_fields': '\n'.join(TEST_EXTRA_FIELDS),
         }
     response = self.post(_getOrgPreferencesEditUrl(self.org), postdata=postdata)
     self.assertResponseRedirect(
@@ -702,6 +707,7 @@
     self.assertEqual(org.slot_request_max, TEST_SLOT_REQUEST_MAX)
     self.assertEqual(org.contrib_template, TEST_CONTRIB_TEMPLATE)
     self.assertEqual(org.scoring_enabled, TEST_SCORING_ENABLED)
+    self.assertEqual(org.extra_fields, TEST_EXTRA_FIELDS)
 
     org_messages = org_model.OrganizationMessages.query(
         ancestor=org.key).get()
@@ -725,3 +731,20 @@
     # check that tab to "Edit Profile" page is the selected one
     self.assertEqual(response.context['tabs'].selected_tab_id,
         tabs.ORG_PREFERENCES_TAB_ID)
+
+
+_TEST_EXTRA_FIELDS = ['field one', 'field two, with comma', 'field - three']
+
+class CleanExtraFieldsTest(unittest.TestCase):
+  """Unit tests for cleanExtraFields function."""
+
+  def testListReturned(self):
+    """Tests that a list of extra fields is returned."""
+    extra_fields = org_app_view.cleanExtraFields('\n'.join(_TEST_EXTRA_FIELDS))
+    self.assertLessEqual(extra_fields, _TEST_EXTRA_FIELDS)
+
+  def testInputTooLong(self):
+    """Tests that too long input is not allowed."""
+    with self.assertRaises(django_forms.ValidationError):
+      org_app_view.cleanExtraFields(
+          'a' * org_app_view.EXTRA_FIELD_MAX_LENGTH * 2)