| # 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_MAIL_SUBJECT = unicode( |
| 'Congratulations! You have been accepted into %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, |
| 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. |
| 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: |
| proposal = proposal_model.Proposal( |
| parent=student_key, **proposal_properties) |
| proposal.put() |
| |
| 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 updateProposal(proposal_key, proposal_properties): |
| """Updates the specified proposal based on the specified properties. |
| |
| Args: |
| proposal_key: Proposal key. |
| proposal_properties: A dict containing 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: |
| proposal.populate(**proposal_properties) |
| proposal.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 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 dispatchAcceptedProposalEmail( |
| 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_MAIL_SUBJECT % program.name, |
| 'proposal_item': proposal_item, |
| } |
| |
| template_string = program_messages.accepted_students_msg |
| |
| mail_dispatcher.getSendMailFromTemplateStringTxn( |
| template_string, context, parent=student)() |
| |
| |
| 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 |