예제 #1
0
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)
예제 #2
0
 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))
예제 #3
0
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)
예제 #4
0
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
예제 #5
0
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]
예제 #6
0
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
예제 #7
0
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
예제 #8
0
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
예제 #9
0
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)
예제 #10
0
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))
예제 #11
0
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 ''
예제 #12
0
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)
예제 #13
0
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,
    )
예제 #14
0
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
예제 #15
0
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
예제 #16
0
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
예제 #17
0
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
예제 #18
0
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