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)