def use_app(user_id): """Update the user's data to mark that they have just used the app.""" user_proto = _get_user_data(user_id) start_of_day = now.get().replace(hour=0, minute=0, second=0, microsecond=0) if user_proto.requested_by_user_at_date.ToDatetime() >= start_of_day: return user_proto user_proto.requested_by_user_at_date.FromDatetime(now.get()) # No need to pollute our DB with super precise timestamps. user_proto.requested_by_user_at_date.nanos = 0 return _save_user(user_proto, is_new_user=False)
def test_favor_tips_never_given(self): """Favor tips that were never sent.""" self.database.advice_modules.insert_one({ 'adviceId': 'my-advice', 'tipTemplateIds': ['tip-a', 'tip-b'], }) self.database.tip_templates.insert_many([ { '_id': 'tip-a', 'actionTemplateId': 'tip-a', 'title': 'Tip already sent', 'isReadyForEmail': True, }, { '_id': 'tip-b', 'actionTemplateId': 'tip-b', 'title': 'Tip never sent', 'isReadyForEmail': True, }, ]) user_id = mongomock.ObjectId() self.database.email_history.update_one( {'_id': user_id}, {'$set': {'tips.tip-a': now.get().isoformat() + 'Z'}}, upsert=True, ) tips = advisor.select_tips_for_email( user_pb2.User(user_id=str(user_id)), project_pb2.Project(), advisor_pb2.AdviceModule(advice_id='my-advice'), self.database, num_tips=1) self.assertEqual(['Tip never sent'], sorted(t.title for t in tips))
def select_advice_for_email(user, weekday, database): """Select an advice to promote in a follow-up email.""" if not user.projects: return None project = user.projects[0] if not project.advices: return None easy_advice_modules = _easy_advice_modules(database) history = advisor_pb2.EmailHistory() history_dict = database.email_history.find_one( {'_id': objectid.ObjectId(user.user_id)}) proto.parse_from_mongo(history_dict, history) today = now.get() last_monday = today - datetime.timedelta(days=today.weekday()) def _score_advice(priority_and_advice): """Score piece of advice to compare to others, the higher the better.""" priority, advice = priority_and_advice score = 0 # Enforce priority advice on Mondays (between -10 and +10). if weekday == user_pb2.MONDAY: priority_score = (advice.score or 5) - 10 * priority / len(project.advices) score += priority_score # Enforce easy advice on Fridays (between +0 and +1). if weekday == user_pb2.FRIDAY and advice.advice_id in easy_advice_modules: score += 1 random_factor = (advice.score or 5) / 10 last_sent = history.advice_modules[advice.advice_id].ToDatetime() last_sent_monday = last_sent - datetime.timedelta( days=last_sent.weekday()) # Do not send the same advice in the same week (+0 or -20). if last_sent_monday >= last_monday: score -= 20 # Reduce the random boost if advice was sent in past weeks (*0.2 the # week just before, *0.45 the week before that, *0.585, *0.669, …) . else: num_weeks_since_last_sent = (last_monday - last_sent_monday).days / 7 random_factor *= .2**(1 / num_weeks_since_last_sent) # Randomize pieces of advice with the same score (between +0 and +1). score += random.random() * random_factor return score candidates = sorted(enumerate(project.advices), key=_score_advice, reverse=True) return next(advice for priority, advice in candidates)
def compute_advices_for_project(user, project, database): """Advise on a user project. Args: user: the user's data, mainly used for their profile and features_enabled. project: the project data. It will not be modified. database: access to the MongoDB with market data. Returns: an Advices protobuffer containing a list of recommendations. """ scoring_project = scoring.ScoringProject(project, user.profile, user.features_enabled, database, now=now.get()) scores = {} advice_modules = _advice_modules(database) advice = project_pb2.Advices() for module in advice_modules: if not module.is_ready_for_prod and not user.features_enabled.alpha: continue scoring_model = scoring.get_scoring_model(module.trigger_scoring_model) if scoring_model is None: logging.warning( 'Not able to score advice "%s", the scoring model "%s" is unknown.', module.advice_id, module.trigger_scoring_model) continue if user.features_enabled.all_modules: scores[module.advice_id] = 3 else: try: scores[module.advice_id] = scoring_model.score(scoring_project) except Exception: # pylint: disable=broad-except logging.exception('Scoring "%s" crashed for:\n%s\n%s', module.trigger_scoring_model, scoring_project.user_profile, scoring_project.details) modules = sorted(advice_modules, key=lambda m: (scores.get(m.advice_id, 0), m.advice_id), reverse=True) incompatible_modules = set() for module in modules: if not scores.get(module.advice_id): # We can break as others will have 0 score as well. break if module.airtable_id in incompatible_modules and not user.features_enabled.all_modules: continue piece_of_advice = advice.advices.add() piece_of_advice.advice_id = module.advice_id piece_of_advice.num_stars = scores.get(module.advice_id) incompatible_modules.update(module.incompatible_advice_ids) _compute_extra_data(piece_of_advice, module, scoring_project) _maybe_override_advice_data(piece_of_advice, module, scoring_project) return advice
def select_tips_for_email(user, project, piece_of_advice, database, num_tips=3): """Select tips to promote an advice in a follow-up email.""" all_templates = list_all_tips(user, project, piece_of_advice, database, filter_tip=lambda t: t.is_ready_for_email) # TODO(pascal): Factorize with above. history = advisor_pb2.EmailHistory() history_dict = database.email_history.find_one( {'_id': objectid.ObjectId(user.user_id)}) proto.parse_from_mongo(history_dict, history) today = now.get() def _score_tip_template(tip_template): """Score tip template to compare to others, the higher the better.""" score = 0 last_sent = history.tips[tip_template.action_template_id].ToDatetime() # Score higher the tips that have not been sent for longer time. score += (today - last_sent).days # Randomize tips with the same score. score += random.random() return score selected_templates = sorted(all_templates, key=_score_tip_template)[-num_tips:] if not selected_templates: return None # Instantiate actual tips. selected_tips = [ action.instantiate(action_pb2.Action(), user, project, template, set(), database, None, for_email=True) for template in selected_templates ] # Replicate tips if we do not have enough. while len(selected_tips) < num_tips: selected_tips.extend(selected_tips) return selected_tips[:num_tips]
def list_all_tips(user, project, piece_of_advice, database, cache=None, filter_tip=None): """List all available tips for a piece of advice. Args: user: the full user info. project: the project to give tips for. piece_of_advice: the piece of advice to give tips for. database: access to the database to get modules and tips. cache: an optional dict that is used across calls to this function to cache data when scoring tips. filter_tip: a function to select which tips to keep, by default (None) keeps all of them. Returns: An iterable of tips for this module. """ if not cache: cache = {} try: module = next(m for m in _advice_modules(database) if m.advice_id == piece_of_advice.advice_id) except StopIteration: logging.warning('Advice module %s does not exist anymore', piece_of_advice.advice_id) return [] # Get tip templates. all_tip_templates = _tip_templates(database) tip_templates = filter(None, (all_tip_templates.get(t) for t in module.tip_template_ids)) # Additional filter from caller. if filter_tip: tip_templates = filter(filter_tip, tip_templates) # Filter tips. scoring_project = cache.get('scoring_project') if not scoring_project: scoring_project = scoring.ScoringProject(project, user.profile, user.features_enabled, database, now=now.get()) cache['scoring_project'] = scoring_project filtered_tips = scoring.filter_using_score(tip_templates, lambda t: t.filters, scoring_project) return filtered_tips
def instantiate(action, user_proto, project, template, for_email=False): """Instantiate a newly created action from a template. Args: action: the action to be populated from the template. user_proto: the whole user data. project: the whole project data. template: the action template to instantiate. for_email: whether the action is to be sent in an email. Returns: the populated action for chaining. """ action.action_id = '%s-%s-%x-%x' % ( project.project_id, template.action_template_id, round( time.time()), random.randrange(0x10000)) action.action_template_id = template.action_template_id action.title = template.title action.title_feminine = template.title_feminine action.short_description = template.short_description action.short_description_feminine = template.short_description_feminine action.link = populate_template(template.link, project.mobility.city, project.target_job) action.how_to = template.how_to action.status = action_pb2.ACTION_UNREAD action.created_at.FromDatetime(now.get()) action.image_url = template.image_url if for_email: if template.email_title: action.title = template.email_title action.link_label = template.email_link_label action.keyword = template.email_subject_keyword if (template.special_generator == action_pb2.LA_BONNE_BOITE and user_proto.features_enabled.lbb_integration == user_pb2.ACTIVE): _get_company_from_lbb(project, action.apply_to_company) if action.apply_to_company.name: title_match = _ANY_COMPANY_REGEXP.match(action.title) if title_match: company_name = action.apply_to_company.name if action.apply_to_company.city_name: company_name += ' (%s)' % action.apply_to_company.city_name else: logging.warning( 'LBB Action %s is missing a city name (user %s).', action.action_id, user_proto.user_id) action.title = title_match.group( 1) + " l'entreprise : " + company_name else: logging.warning( 'LBB Action %s does not have a title that can be updated (user %s).', action.action_id, user_proto.user_id) return action
def _maybe_generate_new_action_plan(user_proto, project): if project.is_incomplete: return False if _project_in_advisor(project): # Do not generate actions for projects handled by the Advisor. return False now_instant = now.get() this_morning = now_instant.replace(hour=3, minute=0, second=0, microsecond=0) if this_morning > now_instant: this_morning -= datetime.timedelta(hours=24) if project.actions_generated_at.ToDatetime() > this_morning: return False if not any(project.activated_chantiers.values()): logging.warning('No activated chantiers yet') return False intensity_def = _PROJECT_INTENSITIES.get(project.intensity) if not intensity_def: logging.warning('Intensity is not defined properly %s', project.intensity) return False # Renew all actions. for old_action in project.actions: action.stop(old_action, _DB) new_past_actions = [a for a in project.actions] new_past_actions.sort(key=lambda action: action.stopped_at.ToDatetime()) project.past_actions.extend(new_past_actions) del project.actions[:] # Number of white actions to generate. target_white_actions = random.randint( intensity_def.min_applications_per_day, intensity_def.max_applications_per_day) # Number of other actions to generate. target_other_actions = random.randint(intensity_def.min_actions_per_day, intensity_def.max_actions_per_day) project.actions_generated_at.FromDatetime(now_instant) _add_actions_to_project(user_proto, project, target_white_actions, use_white_chantiers=True) _add_actions_to_project(user_proto, project, target_other_actions) return True
def project_events(user_id, project_id): """Retrieve a list of associations for a project.""" user_proto = _get_user_data(user_id) project = _get_project_data(user_proto, project_id) scoring_project = scoring.ScoringProject(project, user_proto.profile, user_proto.features_enabled, _DB, now=now.get()) events = scoring_project.list_events() sorted_events = sorted(events, key=lambda j: (j.start_date, -len(j.filters), random.random())) return event_pb2.Events(events=sorted_events)
def stop(action, database): """Mark an action as stopped and handle its cool down time.""" if action.HasField('stopped_at'): return action.stopped_at.FromDatetime(now.get()) if action.status == action_pb2.ACTION_SNOOZED: action.end_of_cool_down.FromDatetime(now.get()) return if action.status in (action_pb2.ACTION_UNREAD, action_pb2.ACTION_CURRENT, action_pb2.ACTION_STUCK): # This action was not completed, so we will show it later, but not for # the next 2 days so that actions change every day. action.end_of_cool_down.FromDatetime(now.get() + datetime.timedelta(days=2)) return if action.status not in (action_pb2.ACTION_DONE, action_pb2.ACTION_STICKY_DONE): return action_template = templates(database).get(action.action_template_id) if not action_template or action_template.cool_down_duration_days == 0: return action.end_of_cool_down.FromDatetime(now.get() + datetime.timedelta( days=action_template.cool_down_duration_days))
def get_employment_status(): """Save user's first click and redirect them to the full survey.""" if any(param not in flask.request.args for param in ('user', 'token')): flask.abort(422, 'Paramètres manquants.') user_id = flask.request.args.get('user') auth_token = flask.request.args.get('token') try: auth.check_token(user_id, auth_token, role='employment-status') except ValueError: flask.abort(403, 'Accès non autorisé.') user_proto = _get_user_data(user_id) if 'id' in flask.request.args: survey_id = int(flask.request.args.get('id')) if survey_id >= len(user_proto.employment_status): flask.abort(422, 'Id invalide.') employment_status = user_proto.employment_status[survey_id] json_format.ParseDict(flask.request.args, employment_status, ignore_unknown_fields=True) _DB.user.update_one({'_id': _safe_object_id(user_id)}, { '$set': { 'employment_status.%s' % survey_id: json_format.MessageToDict(employment_status) } }, upsert=False) else: survey_id = len(user_proto.employment_status) employment_status = user_pb2.EmploymentStatus() employment_status.created_at.FromDatetime(now.get()) json_format.ParseDict(flask.request.args, employment_status, ignore_unknown_fields=True) _DB.user.update_one({'_id': _safe_object_id(user_id)}, { '$push': { 'employment_status': json_format.MessageToDict(employment_status) } }, upsert=False) if 'redirect' in flask.request.args: return flask.redirect('{}?{}'.format( flask.request.args.get('redirect'), parse.urlencode({ 'user': user_id, 'token': auth_token, 'id': survey_id, }))) return ''
def _get_expanded_card_data(user_proto, project, advice_id): module = advisor.get_advice_module(advice_id, _DB) if not module or not module.trigger_scoring_model: flask.abort(404, 'Le module "{}" n\'existe pas'.format(advice_id)) model = scoring.get_scoring_model(module.trigger_scoring_model) if not model or not hasattr(model, 'get_expanded_card_data'): flask.abort( 404, 'Le module "{}" n\'a pas de données supplémentaires'.format( advice_id)) scoring_project = scoring.ScoringProject(project, user_proto.profile, user_proto.features_enabled, _DB, now=now.get()) return model.get_expanded_card_data(scoring_project)
def get_usage_stats(): """Get stats of the app usage.""" now_utc = now.get().astimezone(datetime.timezone.utc) start_of_second = now_utc.replace(microsecond=0, tzinfo=None) last_week = start_of_second - datetime.timedelta(days=7) yesterday = start_of_second - datetime.timedelta(days=1) # Compute daily scores count. daily_scores_count = collections.defaultdict(int) last_day_users = _DB.user.find( { 'registeredAt': { '$gt': _datetime_to_json_string(yesterday), '$lte': _datetime_to_json_string(start_of_second), }, }, { 'profile.email': 1, 'projects': 1, 'registeredAt': 1, }, ) for user_dict in last_day_users: user_proto = user_pb2.User() proto.parse_from_mongo(user_dict, user_proto) if _is_test_user(user_proto): continue for project in user_proto.projects: if project.feedback.score: daily_scores_count[project.feedback.score] += 1 # Compute weekly user count. weekly_new_user_count = _DB.user.find({ 'registeredAt': { '$gt': _datetime_to_json_string(last_week), '$lte': _datetime_to_json_string(start_of_second), } }).count() return stats_pb2.UsersCount( total_user_count=_DB.user.count(), weekly_new_user_count=weekly_new_user_count, daily_scores_count=daily_scores_count, )
def _create_dashboard_export(user_id): """Create an export of the user's current dashboard.""" user_proto = _get_user_data(user_id) dashboard_export = export_pb2.DashboardExport() all_chantiers = _chantiers() for project in user_proto.projects: if not project.is_incomplete: dashboard_export.projects.add().CopyFrom(project) for chantier_id, active in project.activated_chantiers.items(): if not active or chantier_id in dashboard_export.chantiers: continue chantier = all_chantiers.get(chantier_id) if chantier: dashboard_export.chantiers[chantier_id].CopyFrom(chantier) dashboard_export.created_at.FromDatetime(now.get()) export_json = json_format.MessageToDict(dashboard_export) export_json['_id'] = _get_unguessable_object_id() result = _DB.dashboard_exports.insert_one(export_json) dashboard_export.dashboard_export_id = str(result.inserted_id) return dashboard_export
def _save_user(user_data, is_new_user): _tick('Save user start') if is_new_user: previous_user_data = user_data else: _tick('Load old user data') previous_user_data = _get_user_data(user_data.user_id) if not previous_user_data.registered_at.seconds: user_data.registered_at.FromDatetime(now.get()) # No need to pollute our DB with super precise timestamps. user_data.registered_at.nanos = 0 # Enable Advisor for new users. if not ADVISOR_DISABLED_FOR_TESTING: user_data.features_enabled.advisor = user_pb2.ACTIVE user_data.features_enabled.advisor_email = user_pb2.ACTIVE user_data.profile.email_days.extend( [user_pb2.MONDAY, user_pb2.WEDNESDAY, user_pb2.FRIDAY]) # Send an NPS email the next day. user_data.features_enabled.net_promoter_score_email = user_pb2.NPS_EMAIL_PENDING else: user_data.registered_at.CopyFrom(previous_user_data.registered_at) if not _TEST_USER_REGEXP.search(previous_user_data.profile.email): user_data.features_enabled.advisor = previous_user_data.features_enabled.advisor user_data.features_enabled.net_promoter_score_email = \ previous_user_data.features_enabled.net_promoter_score_email _tick('Unverified data zone check start') # TODO(guillaume): Check out how we could not recompute that every time gracefully. if _is_in_unverified_data_zone(user_data.profile, user_data.projects): user_data.app_not_available = True _tick('Unverified data zone check end') _populate_feature_flags(user_data) for project in user_data.projects: if project.is_incomplete: continue _tick('Process project start') rome_id = project.target_job.job_group.rome_id if not project.project_id: # Add ID, timestamp and stats to new projects project.project_id = _create_new_project_id(user_data) project.source = project_pb2.PROJECT_MANUALLY_CREATED project.created_at.FromDatetime(now.get()) _tick('Populate local stats') if not project.HasField('local_stats'): _populate_job_stats_dict({rome_id: project.local_stats}, project.mobility.city) _tick('Advisor') advisor.maybe_advise(user_data, project, _DB) _tick('Stop actions') for current_action in project.actions: if current_action.status in _ACTION_STOPPED_STATUSES: action.stop(current_action, _DB) for past_action in project.past_actions: action.stop(past_action, _DB) for sticky_action in project.sticky_actions: if not sticky_action.HasField('stuck_at'): sticky_action.stuck_at.FromDatetime(now.get()) _tick('Process project end') if not is_new_user: _assert_no_credentials_change(previous_user_data, user_data) _copy_unmodifiable_fields(previous_user_data, user_data) _populate_feature_flags(user_data) # Modifications on user_data after this point will not be saved. _tick('Save user') user_dict = json_format.MessageToDict(user_data) user_dict.update(_SERVER_TAG) if is_new_user: user_dict['_id'] = _get_unguessable_object_id() result = _DB.user.insert_one(user_dict) user_data.user_id = str(result.inserted_id) else: _DB.user.replace_one({'_id': _safe_object_id(user_data.user_id)}, user_dict) _tick('Return user proto') return user_data
def _save_user(user_data, is_new_user): _tick('Save user start') if is_new_user: previous_user_data = user_data else: _tick('Load old user data') previous_user_data = _get_user_data(user_data.user_id) if user_data.revision and previous_user_data.revision > user_data.revision: # Do not overwrite newer data that was saved already: just return it. return previous_user_data if not previous_user_data.registered_at.seconds: user_data.registered_at.FromDatetime(now.get()) # No need to pollute our DB with super precise timestamps. user_data.registered_at.nanos = 0 # Enable Advisor for new users. if not ADVISOR_DISABLED_FOR_TESTING: user_data.features_enabled.advisor = user_pb2.ACTIVE user_data.features_enabled.advisor_email = user_pb2.ACTIVE user_data.profile.email_days.extend( [user_pb2.MONDAY, user_pb2.WEDNESDAY, user_pb2.FRIDAY]) # Send an NPS email the next day. user_data.features_enabled.net_promoter_score_email = user_pb2.NPS_EMAIL_PENDING else: user_data.registered_at.CopyFrom(previous_user_data.registered_at) if not _is_test_user(previous_user_data): user_data.features_enabled.advisor = previous_user_data.features_enabled.advisor user_data.features_enabled.net_promoter_score_email = \ previous_user_data.features_enabled.net_promoter_score_email _tick('Unverified data zone check start') # TODO(guillaume): Check out how we could not recompute that every time gracefully. if _is_in_unverified_data_zone(user_data.profile, user_data.projects): user_data.app_not_available = True _tick('Unverified data zone check end') _populate_feature_flags(user_data) for project in user_data.projects: if project.is_incomplete: continue _tick('Process project start') rome_id = project.target_job.job_group.rome_id if not project.project_id: # Add ID, timestamp and stats to new projects project.project_id = _create_new_project_id(user_data) project.source = project_pb2.PROJECT_MANUALLY_CREATED project.created_at.FromDatetime(now.get()) _tick('Populate local stats') if not project.HasField('local_stats'): _populate_job_stats_dict({rome_id: project.local_stats}, project.mobility.city) _tick('Advisor') advisor.maybe_advise(user_data, project, _DB, parse.urljoin(flask.request.base_url, '/')[:-1]) _tick('New feedback') if not is_new_user and (project.feedback.text or project.feedback.score): previous_project = next((p for p in previous_user_data.projects if p.project_id == project.project_id), project_pb2.Project()) if project.feedback.score > 2 and not previous_project.feedback.score: score_text = \ ':sparkles: General feedback score: %s' % (':star:' * project.feedback.score) else: score_text = '' if project.feedback.text and not previous_project.feedback.text: _give_feedback(feedback_pb2.Feedback( user_id=str(user_data.user_id), project_id=str(project.project_id), feedback=project.feedback.text, source=feedback_pb2.PROJECT_FEEDBACK), extra_text=score_text) else: _tell_slack(score_text) _tick('Process project end') if not is_new_user: _assert_no_credentials_change(previous_user_data, user_data) _copy_unmodifiable_fields(previous_user_data, user_data) _populate_feature_flags(user_data) user_data.revision += 1 # Modifications on user_data after this point will not be saved. _tick('Save user') user_dict = json_format.MessageToDict(user_data) user_dict.update(_SERVER_TAG) if is_new_user: user_dict['_id'] = _get_unguessable_object_id() result = _DB.user.insert_one(user_dict) user_data.user_id = str(result.inserted_id) else: _DB.user.replace_one({'_id': _safe_object_id(user_data.user_id)}, user_dict) _tick('Return user proto') return user_data
def instantiate(action, user_proto, project, template, activated_chantiers, database, all_chantiers, for_email=False): """Instantiate a newly created action from a template. Args: action: the action to be populated from the template. user_proto: the whole user data. project: the whole project data. template: the action template to instantiate. activated_chantiers: a set of chantier IDs that are active. database: access to the Mongo DB. all_chantiers: a dict of all chantiers. for_email: whether the action is to be sent in an email. Returns: the populated action for chaining. """ action.action_id = '%s-%s-%x-%x' % ( project.project_id, template.action_template_id, round( time.time()), random.randrange(0x10000)) action.action_template_id = template.action_template_id action.title = template.title action.title_feminine = template.title_feminine action.short_description = template.short_description action.short_description_feminine = template.short_description_feminine action.link = populate_template(template.link, project.mobility.city, project.target_job) action.how_to = template.how_to action.status = action_pb2.ACTION_UNREAD action.created_at.FromDatetime(now.get()) action.image_url = template.image_url if for_email: if template.email_title: action.title = template.email_title action.link_label = template.email_link_label action.keyword = template.email_subject_keyword action.goal = template.goal action.short_goal = template.short_goal action.sticky_action_incentive = template.sticky_action_incentive sticky_action_steps = _sticky_action_steps(database) action.steps.extend([ sticky_action_steps.get(step_id) for step_id in template.step_ids if sticky_action_steps.get(step_id) ]) for i, step in enumerate(action.steps): step.step_id = '%s-%x' % (action.action_id, i) # Populate all string fields as templates. for field_descriptor in step.DESCRIPTOR.fields: if field_descriptor.type != field_descriptor.TYPE_STRING: continue field_name = field_descriptor.name field = getattr(step, field_name) if field: setattr( step, field_name, populate_template(field, project.mobility.city, project.target_job)) if (template.special_generator == action_pb2.LA_BONNE_BOITE and user_proto.features_enabled.lbb_integration == user_pb2.ACTIVE): _get_company_from_lbb(project, action.apply_to_company) if action.apply_to_company.name: title_match = _ANY_COMPANY_REGEXP.match(action.title) if title_match: company_name = action.apply_to_company.name if action.apply_to_company.city_name: company_name += ' (%s)' % action.apply_to_company.city_name else: logging.warning( 'LBB Action %s is missing a city name (user %s).', action.action_id, user_proto.user_id) action.title = title_match.group( 1) + " l'entreprise : " + company_name else: logging.warning( 'LBB Action %s does not have a title that can be updated (user %s).', action.action_id, user_proto.user_id) for chantier_id in activated_chantiers & set(template.chantiers): chantier = action.chantiers.add() chantier.chantier_id = chantier_id chantier.kind = all_chantiers[chantier_id].kind chantier.title = all_chantiers[chantier_id].title chantier.title_first_person = all_chantiers[ chantier_id].title_first_person return action
def _add_actions_to_project(user_proto, project, num_adds, use_white_chantiers=False): if num_adds < 0: return False all_chantiers = _chantiers() if use_white_chantiers: activated_chantiers = _white_chantier_ids() else: activated_chantiers = set( chantier_id for chantier_id, activated in project.activated_chantiers.items() if activated and chantier_id in all_chantiers) if not activated_chantiers: logging.warning('No activated chantiers') return False # List all action template IDs for which we already had an action in the # near past. now_instant = now.get() still_hot_action_template_ids = set() for hot_action in itertools.chain(project.actions, project.past_actions, project.sticky_actions): if (hot_action.HasField('end_of_cool_down') and hot_action.end_of_cool_down.ToDatetime() < now_instant): continue still_hot_action_template_ids.add(hot_action.action_template_id) # List all action templates that are at least in one of the activated # chantiers. actions_pool = [ a for action_template_id, a in action.templates(_DB).items() # Do not add an action that was already taken. if action_template_id not in still_hot_action_template_ids and # Only add actions that are meant for these chantiers. activated_chantiers & set(a.chantiers) ] # Filter action templates using the filters field. scoring_project = scoring.ScoringProject(project, user_proto.profile, user_proto.features_enabled, _DB) filtered_actions_pool = scoring.filter_using_score(actions_pool, lambda a: a.filters, scoring_project) # Split action templates by priority. pools = collections.defaultdict(list) for filtered_action in filtered_actions_pool: pools[filtered_action.priority_level].append(filtered_action) if not pools: logging.warning( 'No action template would match:\n' ' - %d activated chantiers\n' ' - %d total action templates\n' ' - %d action templates still hot\n' ' - %d before filtering', len(activated_chantiers), len(action.templates(_DB)), len(still_hot_action_template_ids), len(actions_pool)) return False added = False for priority in sorted(pools.keys(), reverse=True): pool = pools[priority] # Pick the number of actions to add if enough. if num_adds == 0: return added if len(pool) > num_adds: pool = random.sample(pool, num_adds) num_adds = 0 else: num_adds -= len(pool) random.shuffle(pool) for template in pool: added = True action.instantiate(project.actions.add(), user_proto, project, template, activated_chantiers, _DB, _chantiers()) return added