blob: 48b40adb9bd68385f94f117f0762408c6f0825c0 [file] [log] [blame]
# Copyright 2014 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.
"""Logic for proposals."""
import collections
import logging
from google.appengine.api import datastore_errors
from google.appengine.ext import ndb
from django.utils import translation
from melange.logic import timeline as timeline_logic
from melange.models import connection as connection_model
from melange.utils import rich_bool
from soc.logic import mail_dispatcher
from summerofcode.logic import timeline as soc_timeline_logic
from summerofcode.models import project as project_model
from summerofcode.models import proposal as proposal_model
from summerofcode.request import links
INVALID_SCORE_VALUE = 'invalid_score_value'
PROPOSAL_LIMIT_REACHED = 'proposal_limit_reached'
SCORING_NOT_ENABLED = 'scoring_not_enabled'
ILLEGAL_PROPOSAL_STATE = 'illegal_propsoal_state'
PROPOSAL_UNEDITABLE_AT_THIS_TIME = 'proposal_uneditable_at_this_time'
NO_MENTOR_ASSIGNED = 'no_mentor_assigned'
# list of properties of Proposal model which cannot be updated
# by updateProposal function
_PROPERTIES_NOT_FOR_UPDATE = ['program', 'organization', 'status']
_PRIVATE_VISIBILITY = 'private'
_PUBLIC_VISIBILITY = 'public'
_NEW_COMMENT_MAIL_SUBJECT = translation.ugettext(
'[%(org_name)s] New %(visibility)s comment on proposal: %(proposal_title)s')
_DEF_NEW_PROPOSAL_MAIL_SUBJECT = translation.ugettext(
'[%(org_name)s] New proposal by %(student_name)s: %(proposal_title)s')
_NEW_COMMENTL_MAIL_TEMPLATE = 'soc/notification/new_comment.html'
_DEF_NEW_PROPOSAL_MAIL_TEMPLATE = 'soc/notification/new_proposal.html'
_DEF_PROPOSAL_ACCEPTED_STUDENT_MAIL_SUBJECT = unicode(
'Congratulations! You have been accepted into %s')
_DEF_PROPOSAL_ACCEPTED_ADMIN_MAIL_SUBJECT = unicode(
'Proposal Accepted: %s')
_DEF_PROPOSAL_REJECTED_MAIL_SUBJECT = translation.ugettext(
'Thank you for applying to %s')
_PROPOSAL_EXISTS = translation.ugettext('Proposal key already exists.')
_PROPOSAL_KEY_NOT_FOUND = translation.ugettext(
'Proposal does not exist in the submitted proposals list.')
_PROPOSAL_RANK_NOT_UNIQUE = translation.ugettext(
'Proposal ranks submitted are not unique.')
_PROPOSAL_RANK_EXCEEDED = translation.ugettext(
'Proposal rank exceeds total number of proposals.')
_PROPOSAL_RANK_INVALID = translation.ugettext(
'Submitted proposal rank is not a valid rank.')
def isProposalLimitReached(profile, program):
"""Tells whether the specified student has already reached limit of submitted
proposals which is defined for the specified program.
Args:
profile: profile_model.Profile entity for the specified student.
program: program_model.Program entity.
Returns:
True, if the limit of proposals is not reached.
Otherwise, False is returned.
Raises:
ValueError: if the specified profile is not a student.
"""
if not profile.is_student:
raise ValueError('The specified profile is not a student')
else:
query = queryProposalsForStudent(profile.key)
query = query.filter(
proposal_model.Proposal.status != proposal_model.Status.WITHDRAWN)
return query.count() >= program.apps_tasks_limit
def canStudentUpdateProposal(proposal, timeline):
"""Tells whether the specified proposal can be updated at this time.
Args:
proposal: Proposal entity.
timeline: Timeline entity for the program for which the proposal is
submitted.
Returns:
rich_bool.RichBool whose value is set to True, if the specified proposal
is allowed to be updated at this time. Otherwise, rich_bool.RichBool whose
value is set to False and extra part is a string that represents the reason
why the proposal cannot be updated.
"""
if timeline_logic.isActiveStudentSignup(timeline) or (
proposal.is_editable_post_deadline
and timeline_logic.isAfterStudentSignup(timeline)):
return rich_bool.TRUE
else:
return rich_bool.RichBool(False, PROPOSAL_UNEDITABLE_AT_THIS_TIME)
@ndb.transactional
def createProposal(student_key, organization, program, proposal_properties,
proposal_revision_properties, profiles_to_notify=None, site=None,
url_names=None):
"""Creates a new proposal for the specified student.
Args:
student_key: ndb.Key of the student profile to which the proposal belongs.
organization: org_model.Organization entity to which
the proposal is created.
program: program_model.Program entity for which the proposal is created.
proposal_properties: A dict mapping proposal properties to their values.
proposal_revision_properties: A dict mapping proposal revision properties
to their values.
profiles_to_notify: Optional list of profile_model.Profile entities
who should be notified that this new proposal is created.
site: site_model.Site entity. It has to be specified only when the list
of profiles to notify is also supplied.
url_names: url_names: Instance of url_names.UrlNames. The value has to be
specified only when the list of profiles to notify is also supplied.
Returns:
rich_bool.RichBool whose value is set to True if proposal has been
successfully created. In that case, extra part points to the newly created
proposal entity. Otherwise, rich_bool.RichBool whose value is set to
False and extra part is a string that represents the reason why the action
could not be completed.
"""
if profiles_to_notify and not (site and url_names):
raise ValueError(
'When profiles_to_notify is specified, both site (%s) and '
'url_names (%s) have to be specified as well.' % (site, url_names))
student = student_key.get()
if isProposalLimitReached(student, program):
return rich_bool.RichBool(False, PROPOSAL_LIMIT_REACHED)
else:
proposal_properties['program'] = student.program
proposal_properties['organization'] = organization.key
try:
# A cyclic reference is being set up here between the parent Proposal
# and the child ProposalRevision
proposal = proposal_model.Proposal(
parent=student_key, **proposal_properties)
proposal.put()
revision = proposal_model.ProposalRevision(
parent=proposal.key, id='0', **proposal_revision_properties)
proposal.latest_revision = revision.key
ndb.put_multi([revision, proposal])
student.student_data.number_of_proposals += 1
if proposal.key in student.student_data.preference_of_proposals:
return rich_bool.RichBool(False, _PROPOSAL_EXISTS)
student.student_data.preference_of_proposals.append(proposal.key)
student.put()
if profiles_to_notify:
proposal_review_url = links.ABSOLUTE_LINKER.userId(
student.key, proposal.key.id(),
url_names.PROPOSAL_REVIEW_AS_ORG_MEMBER)
notification_settings_url = links.ABSOLUTE_LINKER.program(
program, url_names.PROFILE_NOTIFICATION_SETTINGS)
dispatchNewProposalEmail(
profiles_to_notify, proposal, organization, student, program, site,
proposal_review_url, notification_settings_url)
return rich_bool.RichBool(True, proposal)
except ValueError as e:
logging.warning(e)
return rich_bool.RichBool(False, extra=str(e))
except datastore_errors.BadValueError as e:
logging.warning(e)
return rich_bool.RichBool(False, extra=str(e))
except TypeError as e:
logging.warning(e)
return rich_bool.RichBool(False, extra=str(e))
def constructProposalRevisionKeyFromProposal(proposal, revision_id):
"""Constructs the ProposalRevision key for the specified proposal.
Args:
proposal: proposal_model.Proposal entity.
revision_id: Non-negative integer representing the revision number of the
proposal_model.ProposalRevision entity.
Returns:
ndb.Key object which is the key of proposal_model.ProposalRevision entity.
"""
proposal_revision_flat = list(proposal.key.flat())
proposal_revision_flat.append(proposal_model.ProposalRevision)
proposal_revision_flat.append(revision_id)
return ndb.Key(flat=proposal_revision_flat)
def updateProposal(proposal_key, proposal_properties,
proposal_revision_properties=None):
"""Updates the specified proposal based on the specified properties.
Args:
proposal_key: Proposal key.
proposal_properties: A dict containing properties to be updated.
proposal_revision_properties: A dict containing proposal revision
properties to be updated.
Returns:
rich_bool.RichBool whose value is set to True if proposal has been
successfully updated. In that case, extra part points to the newly updated
proposal entity. Otherwise, rich_bool.RichBool whose value is set to
False and extra part is a string that represents the reason why the action
could not be completed.
"""
for prop in proposal_properties:
if prop in _PROPERTIES_NOT_FOR_UPDATE:
raise ValueError('Property %s cannot be updated by this function' % prop)
proposal = proposal_key.get()
try:
to_put = []
# This is a guard condition which prevents proposal revisions from being
# created when there is no change in the content property.
if (proposal_revision_properties and
proposal_revision_properties.get('content') != proposal.content):
revision_id = str(proposal.latest_revision.get().revision + 1)
revision_key = constructProposalRevisionKeyFromProposal(
proposal, revision_id)
latest_revision_property = proposal_model.Proposal.latest_revision._name
proposal_properties[latest_revision_property] = revision_key
revision = proposal_model.ProposalRevision(
key=revision_key, **proposal_revision_properties)
to_put.append(revision)
proposal.populate(**proposal_properties)
to_put.append(proposal)
ndb.put_multi(to_put)
return rich_bool.RichBool(True, proposal)
except ValueError as e:
logging.warning(e)
return rich_bool.RichBool(False, extra=str(e))
except datastore_errors.BadValueError as e:
logging.warning(e)
return rich_bool.RichBool(False, extra=str(e))
except TypeError as e:
logging.warning(e)
return rich_bool.RichBool(False, extra=str(e))
def getScoreForProfile(proposal, profile_key):
"""Returns a score submitted by the specified profile to the specified
proposal.
Args:
proposal: proposal_model.Proposal entity.
profile_key: ndb.Key for the profile entity.
Returns:
proposal_model.Score object whose author is the specified profile, or
None if no score exists.
"""
# NOTE: linear search is absolutely fine for the number of scores
# that exists for a proposal. If that number goes up, consider using
# a more efficient structure.
return next(
(score for score in proposal.scores if score.author == profile_key), None)
@ndb.transactional
def updateProposalRanks(profile_key, rank_data):
"""Updates the proposal ranks as supplied by the user.
Args:
profile_key: ndb.Key of a profile who changes the ranks of the proposals.
rank_data: dict containing proposal as key and it's new rank as it's value.
Returns:
rich_bool.RichBool whose value is set to True, if the rank of proposals
has been successfully changed. Otherwise, rich_bool.RichBool whose
value is set to False and an extra part is a string that represents
the reason why the action could not be completed.
"""
profile = profile_key.get()
proposal_prefs = profile.student_data.preference_of_proposals
# Intialize new preference list with fixed length. The fixed length is
# equal to the total number of proposals in the preference list.
new_prefs = [None] * len(proposal_prefs)
# Validate post data and insert proposals with changed ranks into their
# respective indexes in the new preference list.
for proposal_key_str, proposal_properties in rank_data.iteritems():
proposal_key = ndb.Key(urlsafe=proposal_key_str)
if proposal_key not in proposal_prefs:
return rich_bool.RichBool(False, _PROPOSAL_KEY_NOT_FOUND)
new_rank = int(proposal_properties['rank'])
if new_rank <= 0:
return rich_bool.RichBool(False, _PROPOSAL_RANK_INVALID)
elif new_rank > len(proposal_prefs):
return rich_bool.RichBool(False, _PROPOSAL_RANK_EXCEEDED)
elif new_prefs[new_rank - 1]:
return rich_bool.RichBool(False, _PROPOSAL_RANK_NOT_UNIQUE)
new_prefs[new_rank - 1] = proposal_key
# Insert proposals with unchanged ranks without changing their order in the
# new preference list.
new_prefs_index = 0
for proposal_key in proposal_prefs:
if proposal_key in new_prefs:
continue
while new_prefs[new_prefs_index]:
new_prefs_index += 1
new_prefs[new_prefs_index] = proposal_key
new_prefs_index += 1
profile.student_data.preference_of_proposals = new_prefs
profile.put()
return rich_bool.TRUE
@ndb.transactional
def setScoreForProfile(proposal_key, profile_key, value, organization):
"""Sets a score with the specified value for the specified proposal and
for the specified profile.
Args:
proposal_key: ndb.Key of a proposal entity.
profile_key: ndb.Key of a profile who gives the score.
value: Int value for the score or None, if the score is to be cleared.
organization: org_model.Organization entity to which the proposal
corresponds.
Raises:
ValueError: If the specified organization does not correspond to the
specified proposal.
Returns:
rich_bool.RichBool whose value is set to True, if the score has been
successfully set for the proposal. Otherwise, rich_bool.RichBool whose
value is set to False and and extra part is a string that represents
the reason why the action could not be completed.
"""
proposal = proposal_key.get()
if proposal.organization != organization.key:
raise ValueError(
'The specified organization: %s does not correspond to the one '
'which is associated with the specified proposal: %s' % (
organization.key.id(), proposal.organization.id()))
elif value is not None and (organization.max_score < value or 0 >= value):
return rich_bool.RichBool(False, INVALID_SCORE_VALUE)
elif not organization.scoring_enabled:
return rich_bool.RichBool(False, SCORING_NOT_ENABLED)
else:
# remove the current score for the proposal
scores = [score for score in proposal.scores if score.author != profile_key]
if value is not None:
score = proposal_model.Score(value=value, author=profile_key)
scores.insert(0, score)
proposal.scores = scores
proposal.put()
return rich_bool.TRUE
def queryProposalRevisions(proposal_key):
"""Returns a query to fetch all proposal revisions belonging to the specified
proposal.
Args:
proposal_key: ndb.Key of the proposal.
Returns:
ndb.Query object to fetch all proposal revisions for the proposal.
"""
return proposal_model.ProposalRevision.query(ancestor=proposal_key)
def queryProposalsForStudent(student_key):
"""Returns a query to fetch all proposals belonging to the specified student.
Args:
student_key: ndb.Key of the student.
Returns:
ndb.Query object to fetch all proposals for the student.
"""
return proposal_model.Proposal.query(ancestor=student_key)
def queryProposalsForOrganizationMember(profile):
"""Returns a query to fetch all proposals that are visible to
the specified organization member.
An organization member is allowed to see all proposal submitted to their
organization save the withdrawn ones.
Args:
profile_key: profile_model.Profile key of the organization member.
Returns:
ndb.Query object to fetch all proposals for the organization member.
"""
query = proposal_model.Proposal.query(
proposal_model.Proposal.organization.IN(profile.mentor_for))
query = query.order(proposal_model.Proposal.status)
query = query.order(proposal_model.Proposal._key)
return query
def queryProposalsForProgramHost(program_key):
"""Returns a query to fetch all proposals that are visible to a host
for the specified program.
Args:
program_key: Program key.
Returns:
ndb.Query object to fetch all proposals for a program host.
"""
program_key = ndb.Key.from_old_key(program_key)
return proposal_model.Proposal.query(
proposal_model.Proposal.program == program_key)
def queryPendingProposalsForOrganization(org_key):
"""Returns a query to fetch all pending proposals for the specified
organization.
Args:
org_key: Organization key.
Returns:
ndb.Query object to fetch all pending proposals for the organization.
"""
return proposal_model.Proposal.query(
proposal_model.Proposal.status == proposal_model.Status.PENDING,
proposal_model.Proposal.organization == org_key)
@ndb.transactional
def createComment(
proposal, content, author, is_private, profiles_to_notify=None,
student_to_notify=None, program=None, organization=None,
site=None, url_names=None):
"""Create a new comment for the specified proposal.
Please note that the created entity is persisted in the datastore.
Args:
proposal: proposal_model.Proposal entity of the proposal
for which a comment is created.
content: Content of the comment as a string
author: profile_model.Profile entity of the profile who is
the author of the comment.
is_private: A bool telling whether this proposal is private or not.
profiles_to_notify: Optional list of profile_model.Profile entities
who should be notified that this new comment is posted.
student_to_notify: Optional profile_model.Profile of a student profile
who should be notified that this new comment is posted.
program: program_model.Program entity for which the proposal is created.
It has to be specified only when the list of profiles or a student
to notify is also supplied.
organization: org_model.Organization entity for which the proposal
created. It has to be specified only when the list of profiles or
a student to notify is also supplied.
site: site_model.Site entity. It has to be specified only when the list
of profiles or a student to notify is also supplied.
proposal_review_url_name: The name of the appropriate "Proposal Review" view
with which a URL was registered with Django. It has to be specified only
when the list of profiles or a student to notify is also supplied.
url_names: url_names: Instance of url_names.UrlNames. It has to be specified
only when the list of profiles or a student to notify is also supplied.
Returns:
The newly created ConnectionMessage entity.
"""
if ((profiles_to_notify or student_to_notify) and
not (program and organization and site and url_names)):
raise ValueError(
'When profiles_to_notify is specified, all site (%s) and '
'url_names (%s) and program (%s) and organization (%s) and have to '
'be specified as well.' % (site, url_names, program, organization))
comment = connection_model.ConnectionMessage(
parent=proposal.key, content=content, author=author.key,
is_private=is_private)
comment.put()
if profiles_to_notify:
proposal_review_url = links.ABSOLUTE_LINKER.userId(
proposal.key.parent(), proposal.key.id(),
url_names.PROPOSAL_REVIEW_AS_ORG_MEMBER)
notification_settings_url = links.ABSOLUTE_LINKER.program(
program, url_names.PROFILE_NOTIFICATION_SETTINGS)
dispatchNewCommentEmail(
profiles_to_notify, comment, author, proposal, organization,
program, site, proposal_review_url, notification_settings_url)
if student_to_notify:
if is_private:
raise ValueError(
'Notifications of a private comment should not be sent to students')
else:
proposal_review_url = links.ABSOLUTE_LINKER.userId(
proposal.key.parent(), proposal.key.id(),
url_names.PROPOSAL_REVIEW_AS_STUDENT)
notification_settings_url = links.ABSOLUTE_LINKER.program(
program, url_names.PROFILE_NOTIFICATION_SETTINGS)
dispatchNewCommentEmail(
[student_to_notify], comment, author, proposal,
organization, program, site, proposal_review_url,
notification_settings_url)
return comment
def getComments(proposal_key, exclude_private=None):
"""Retrieves all comments for the specified proposal.
Args:
propsoal_key: ndb.Key of the proposal for which comments are to be
retrieved.
exclude_private: If set to True, only non-private are retrieved.
Returns:
A list of ConnectionMessage entities.
"""
query = connection_model.ConnectionMessage.query(ancestor=proposal_key)
if exclude_private:
query = query.filter(connection_model.ConnectionMessage.is_private == False)
return query.fetch(1000)
def dispatchNewCommentEmail(
profiles, comment, author, proposal, organization, program, site,
proposal_review_url, notification_settings_url, parent=None):
"""Dispatches a task to send notification when a new comment is submitted for
the specified proposal.
Args:
profiles: List of profile_model.Profile entities to which an email should
be sent.
comment: connection_model.ConnectionMessage entity.
author: profile_model.Profile entity of the author of the comment.
proposal: proposal_model.Proposal entity.
organization: org_model.Organization entity to which the proposal
is submitted.
program: program_model.Program entity.
site: site_model.Site entity.
proposal_review_url: The URL to "Review Proposal" page. It will be included
in the content of the dispatched email.
notification_settings_url: The URL to "Notification Settings" page. It will
be included in the content of the dispatched email.
parent: Optional entity to use as the parent of the entity which is
created during the process. If not specified, the specified proposal
entity is used.
"""
sender_name, sender = mail_dispatcher.getDefaultMailSender(site=site)
parent = parent or proposal
subject_context = {
'org_name': organization.name,
'visibility':
_PRIVATE_VISIBILITY if comment.is_private else _PUBLIC_VISIBILITY,
'proposal_title': proposal.title,
}
subject = _NEW_COMMENT_MAIL_SUBJECT % subject_context
context = {
'bcc': [profile.contact.email for profile in profiles],
'sender': sender,
'sender_name': sender_name,
'subject': subject,
'program_name': program.name,
'to_name': profile.public_name,
'comment_author': author.public_name,
'comment_content': comment.content,
'proposal_title': proposal.title,
'proposal_review_url': proposal_review_url,
'notification_settings_url': notification_settings_url,
}
mail_dispatcher.getSendMailFromTemplateNameTxn(
_NEW_COMMENTL_MAIL_TEMPLATE, context, parent=parent)()
def dispatchNewProposalEmail(
profiles, proposal, organization, student, program, site,
proposal_review_url, notification_settings_url, parent=None):
"""Dispatches a task to send notification when a new proposal is submitted to
the specified organization.
Args:
profiles: List of profile_model.Profile entities to which an email should
be sent.
proposal: proposal_model.Proposal entity.
organization: org_model.Organization entity to which the proposal
is submitted.
student: profile_model.Profile entity of the student who is the author
of the proposal.
program: program_model.Program entity.
site: site_model.Site entity.
proposal_review_url: The URL to "Review Proposal" page. It will be included
in the content of the dispatched email.
notification_settings_url: The URL to "Notification Settings" page. It will
be included in the content of the dispatched email.
parent: Optional entity to use as the parent of the entity which is
created during the process. If not specified, the specified proposal
entity is used.
"""
sender_name, sender = mail_dispatcher.getDefaultMailSender(site=site)
parent = parent or proposal
subject_context = {
'org_name': organization.name,
'student_name': student.public_name,
'proposal_title': proposal.title,
}
subject = _DEF_NEW_PROPOSAL_MAIL_SUBJECT % subject_context
context = {
'bcc': [profile.contact.email for profile in profiles],
'sender': sender,
'sender_name': sender_name,
'subject': subject,
'program_name': program.name,
'to_name': profile.public_name,
'org_name': organization.name,
'proposal_content': proposal.content,
'proposal_title': proposal.title,
'proposal_review_url': proposal_review_url,
'student_name': student.public_name,
'notification_settings_url': notification_settings_url,
}
mail_dispatcher.getSendMailFromTemplateNameTxn(
_DEF_NEW_PROPOSAL_MAIL_TEMPLATE, context, parent=parent)()
@ndb.transactional
def addPossibleMentor(proposal_key, profile_key):
"""Assigns the specified profile as a possible mentor for the specified
proposal.
Args:
proposal_key: ndb.Key of the proposal.
profile_key: ndb.Key of the profile.
"""
proposal = proposal_key.get()
possible_mentors = proposal.possible_mentors
possible_mentors.append(profile_key)
proposal.possible_mentors = set(possible_mentors)
proposal.put()
@ndb.transactional
def removePossibleMentor(proposal_key, profile_key):
"""Removes the specified profile as a possible mentor for the specified
proposal.
Args:
proposal_key: ndb.Key of the proposal.
profile_key: ndb.Key of the profile.
"""
proposal = proposal_key.get()
proposal.possible_mentors = [
mentor_key for mentor_key in proposal.possible_mentors
if mentor_key != profile_key]
proposal.put()
@ndb.transactional
def withdrawProposal(proposal_key):
"""Withdraws the specified proposal.
Args:
proposal_key: ndb.Key of the proposal.
Returns:
rich_bool.RichBool whose value is set to True, if the specified proposal
is allowed to be withdrawn at this time. Otherwise, rich_bool.RichBool whose
value is set to False and extra part is a string that represents the reason
why the proposal cannot be updated.
"""
# TODO(daniel): it should not be possible to withdraw a proposal after
# proposals are accepted/rejected
proposal = proposal_key.get()
if proposal.status == proposal_model.Status.PENDING:
proposal.status = proposal_model.Status.WITHDRAWN
proposal.put()
profile = proposal_key.parent().get()
profile.student_data.number_of_proposals -= 1
profile.student_data.preference_of_proposals.remove(proposal.key)
profile.put()
return rich_bool.TRUE
elif proposal.status == proposal_model.Status.WITHDRAWN:
return rich_bool.TRUE
elif proposal.status in [
proposal_model.Status.ACCEPTED, proposal_model.Status.REJECTED]:
return rich_bool.RichBool(False, ILLEGAL_PROPOSAL_STATE)
@ndb.transactional
def rejectProposal(proposal_key):
"""Rejects the specified proposal.
Args:
proposal_key: ndb.Key of the proposal.
Returns:
rich_bool.RichBool whose value is set to True, if the specified proposal
is allowed to be rejected at this time. Otherwise, rich_bool.RichBool whose
value is set to False and extra part is a string that represents the reason
why the proposal cannot be updated.
"""
proposal = proposal_key.get()
if proposal.status == proposal_model.Status.PENDING:
proposal.status = proposal_model.Status.REJECTED
proposal.put()
return rich_bool.TRUE
elif proposal.status == proposal_model.Status.REJECTED:
return rich_bool.TRUE
elif proposal.status in [
proposal_model.Status.WITHDRAWN, proposal_model.Status.ACCEPTED]:
return rich_bool.RichBool(False, ILLEGAL_PROPOSAL_STATE)
@ndb.transactional
def resubmitProposal(proposal_key, program):
"""Resubmits the specified proposal.
Args:
proposal_key: ndb.Key of the proposal.
Returns:
rich_bool.RichBool whose value is set to True, if the specified proposal
is allowed to be resubmitted at this time. Otherwise, rich_bool.RichBool
whose value is set to False and extra part is a string that represents
the reason why the proposal cannot be updated.
"""
# TODO(daniel): it should not be possible to resubmit a proposal after
# proposals are accepted/rejected
proposal = proposal_key.get()
if proposal.status == proposal_model.Status.WITHDRAWN:
profile = proposal_key.parent().get()
if isProposalLimitReached(profile, program):
return rich_bool.RichBool(False, PROPOSAL_LIMIT_REACHED)
else:
proposal.status = proposal_model.Status.PENDING
proposal.put()
profile.student_data.number_of_proposals += 1
profile.student_data.preference_of_proposals.append(proposal.key)
profile.put()
return rich_bool.TRUE
elif proposal.status == proposal_model.Status.PENDING:
return rich_bool.TRUE
elif proposal.status in [
proposal_model.Status.ACCEPTED, proposal_model.Status.REJECTED]:
return rich_bool.RichBool(False, ILLEGAL_PROPOSAL_STATE)
@ndb.transactional
def assignMentors(proposal_key, mentors):
"""Assigns the specified mentors to the specified proposal.
Args:
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
whose value is set to False and extra part is a list of profile entities
of users who are not eligible to be assigned as mentors.
"""
proposal = proposal_key.get()
not_valid = [
mentor for mentor in mentors
if proposal.organization not in mentor.mentor_for]
if not_valid:
return rich_bool.RichBool(False, not_valid)
else:
proposal.mentors = [mentor.key for mentor in mentors]
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()
def getProposalsToBeAcceptedForOrg(organization):
"""Returns proposals which are to be accepted into the program
for the specified organization.
Args:
organization: org_model.Organization entity.
Returns:
List of proposal_model.Proposal entities to be accepted into the program.
"""
# check if there are already slots taken by this organization
query = proposal_model.Proposal.query(
proposal_model.Proposal.organization == organization.key,
proposal_model.Proposal.status == proposal_model.Status.ACCEPTED)
slots_left_to_assign = max(0, organization.slot_allocation - query.count())
if not slots_left_to_assign:
# no slots left so return nothing
return []
else:
query = proposal_model.Proposal.query(
proposal_model.Proposal.organization == organization.key,
proposal_model.Proposal.status == proposal_model.Status.PENDING,
proposal_model.Proposal.accept_as_project == True,
proposal_model.Proposal.has_mentor == True)
query = query.order(-proposal_model.Proposal.total_score)
return query.fetch(slots_left_to_assign)
def getUsedSlots(org_key, timeline):
"""Returns number of slots that have been used by the specified organization.
Specifically, number of proposals which have been actually accepted
into projects is returned after accepted students are announced for
the program. Before that date, number of pending proposals which have been
marked as to-be-accepted by the organization administrators and meet
all other requirements, i.e. are not withdrawn and have a mentor assigned,
is returned.
Args:
org_key: ndb.Key of organization entity.
timeline: Timeline entity.
Returns:
An int describing the number of used proposals as described in
the documentation.
"""
if soc_timeline_logic.isAfterStudentsAnnounced(timeline):
query = proposal_model.Proposal.query(
proposal_model.Proposal.organization == org_key,
proposal_model.Proposal.status == proposal_model.Status.ACCEPTED)
else:
query = proposal_model.Proposal.query(
proposal_model.Proposal.organization == org_key,
proposal_model.Proposal.status == proposal_model.Status.PENDING,
proposal_model.Proposal.accept_as_project == True,
proposal_model.Proposal.has_mentor == True)
return query.count()
@ndb.transactional
def acceptProposal(proposal_key):
"""Accepts the specified proposal and converts it into a project for
the program.
Args:
proposal_key: ndb.Key of the proposal.
Returns:
rich_bool.RichBool whose value is set to True, if the specified proposal
is successfully accepted and converted into a project. In that case,
extra part points to the newly created project entity.
Otherwise, rich_bool.RichBool whose value is set to False and extra part is
a string that represents the reason why the proposal cannot be updated.
"""
# try finding an existing project for the proposal
project = project_model.Project.query(
project_model.Project.proposal == proposal_key,
ancestor=proposal_key.parent()).get()
if project:
return rich_bool.RichBool(True, project)
else:
proposal, profile = ndb.get_multi([proposal_key, proposal_key.parent()])
if proposal.status == proposal_model.Status.WITHDRAWN:
return rich_bool.RichBool(False, ILLEGAL_PROPOSAL_STATE)
elif not proposal.mentors:
return rich_bool.RichBool(False, NO_MENTOR_ASSIGNED)
else:
proposal.status = proposal_model.Status.ACCEPTED
properties = {
'abstract': proposal.abstract,
'mentors': proposal.mentors,
'organization': proposal.organization,
'parent': proposal_key.parent(),
'program': proposal.program,
'proposal': proposal_key,
'status': project_model.Status.ACCEPTED,
'title': proposal.title,
}
project = project_model.Project(**properties)
profile.student_data.number_of_projects += 1
project_for_orgs = profile.student_data.project_for_orgs
project_for_orgs.append(proposal.organization)
project_for_orgs = set(project_for_orgs)
profile.student_data.project_for_orgs = project_for_orgs
ndb.put_multi([proposal, project, profile])
return rich_bool.RichBool(True, project)
ProposalItem = collections.namedtuple(
'ProposalItem', ['proposal', 'organization', 'org_messages'])
# TODO(daniel): figure out whether accepted students should be informed
# about their rejected proposals
def dispatchStudentAcceptedProposalEmail(
student, program, program_messages, site, proposal_item):
"""Dispatches a task to sent proposal acceptance email to the specified
student.
Args:
student: profile_model.Profile entity of a student whose proposal
is accepted.
program: program_model.Program entity.
program_messages: program_model.ProgramMessages entity.
site: site_model.Site entity.
proposal_item: ProposalItem instance containing data
for the accepted proposal.
"""
sender_name, sender = mail_dispatcher.getDefaultMailSender(site=site)
context = {
'to': student.contact.email,
'to_name': student.public_name,
'sender': sender,
'sender_name': sender_name,
'program_name': program.name,
'subject': _DEF_PROPOSAL_ACCEPTED_STUDENT_MAIL_SUBJECT % program.name,
'proposal_item': proposal_item,
}
template_string = program_messages.accepted_students_msg
mail_dispatcher.getSendMailFromTemplateStringTxn(
template_string, context, parent=student)()
def dispatchAdminAcceptedProposalEmail(
admin, program, program_messages, site, proposal_item):
"""Dispatches a task to sent email to the admin for each proposal
accepted.
Args:
admin: profile_model.Profile entity of a admin to whom mail is to
be sent.
program: program_model.Program entity.
program_messages: program_model.ProgramMessages entity.
site: site_model.Site entity.
proposal_item: ProposalItem instance containing data
for the accepted proposal.
"""
sender_name, sender = mail_dispatcher.getDefaultMailSender(site=site)
context = {
'to': admin.contact.email,
'to_name': admin.public_name,
'sender': sender,
'sender_name': sender_name,
'program_name': program.name,
'proposal_item': proposal_item,
'subject': _DEF_PROPOSAL_ACCEPTED_ADMIN_MAIL_SUBJECT %
proposal_item.proposal.title,
}
template_string = program_messages.accepted_proposal_admin_msg
mail_dispatcher.getSendMailFromTemplateStringTxn(
template_string, context, parent=admin)()
def dispatchRejectedProposalEmail(
student, program, program_messages, site, proposal_items):
"""Dispatches a task to sent proposal rejection email to the specified
student.
Args:
student: profile_model.Profile entity of a student whose proposals
are rejected.
program: program_model.Program entity.
program_messages: program_model.ProgramMessages entity.
site: site_model.Site entity.
proposal_item: Iterable of ProposalItem instances containing data
for the rejected proposals.
"""
sender_name, sender = mail_dispatcher.getDefaultMailSender(site=site)
context = {
'to': student.contact.email,
'to_name': student.public_name,
'sender': sender,
'sender_name': sender_name,
'program_name': program.name,
'subject': _DEF_PROPOSAL_REJECTED_MAIL_SUBJECT % program.name,
'proposal_items': proposal_items,
}
template_string = program_messages.rejected_students_msg
mail_dispatcher.getSendMailFromTemplateStringTxn(
template_string, context, parent=student)()
def getProposalRank(proposal):
"""Return rank for the specified proposal according to student's
preference list.
Args:
proposal: proposal_model.Proposal entity.
Returns:
An int specifying rank of the proposal. Zero is returned if the rank
is unknown.
"""
if proposal.status == proposal_model.Status.WITHDRAWN:
return 0
else:
profile = proposal.key.parent().get()
for index, key in enumerate(profile.student_data.preference_of_proposals):
if key == proposal.key:
return index + 1
else:
# NOTE: this is needed to support old proposals with no rank information.
return 0