def test_mixed_case(self) -> None: """First name has various casing.""" self.assertEqual('Pascal', french.cleanup_firstname('pascal')) self.assertEqual('Pascal', french.cleanup_firstname('PASCAL')) self.assertEqual('Pascal', french.cleanup_firstname('pASCAL')) self.assertEqual('Pascal', french.cleanup_firstname('PaScAl'))
def test_apostrophe(self) -> None: """First name contains an apostrophe.""" self.assertEqual("D'Arles", french.cleanup_firstname("D'ARLES")) # This one is not that great, but we'll keep it here to make it obvious # what the result is. As it's not really a first name, I think it's ok. self.assertEqual("Cap'Tain", french.cleanup_firstname("CAP'TAIN"))
def _pe_connect_authenticate( self, auth_request: auth_pb2.AuthRequest) -> auth_pb2.AuthResponse: token_data = _get_oauth2_access_token( 'https://authentification-candidat.pole-emploi.fr/connexion/oauth2/access_token?' 'realm=/individu', code=auth_request.pe_connect_code, client_id=_EMPLOI_STORE_CLIENT_ID or '', client_secret=_EMPLOI_STORE_CLIENT_SECRET or '', auth_name='PE Connect', ) if token_data.get('nonce') != auth_request.pe_connect_nonce: flask.abort(403, i18n.flask_translate('Mauvais paramètre nonce')) bearer = token_data.get('token_type', 'Bearer') access_token = token_data.get('access_token', '') authorization_header = f'{bearer} {access_token}' user_info_response = requests.get( 'https://api.emploi-store.fr/partenaire/peconnect-individu/v1/userinfo', headers={'Authorization': authorization_header}) if user_info_response.status_code < 200 or user_info_response.status_code >= 400: logging.warning('PE Connect fails (%d): "%s"', user_info_response.status_code, user_info_response.text) flask.abort(403, user_info_response.text) user_info = typing.cast(dict[str, str], user_info_response.json()) response = auth_pb2.AuthResponse() user_dict = self._user_collection.find_one( {'peConnectId': user_info['sub']}) if proto.parse_from_mongo(user_dict, response.authenticated_user, 'user_id'): self._handle_returning_user(response) else: user = response.authenticated_user is_existing_user = self._load_user_from_token_or_email( auth_request, user, user_info.get('email')) user.pe_connect_id = user_info['sub'] response.is_new_user = force_update = not user.has_account user.has_account = True if is_existing_user: self._handle_returning_user(response, force_update=force_update) else: # TODO(pascal): Handle the case where one of the name is missing. user.profile.name = french.cleanup_firstname( user_info.get('given_name', '')) user.profile.last_name = french.cleanup_firstname( user_info.get('family_name', '')) user.profile.gender = _PE_CONNECT_GENDER.get( user_info.get('gender', ''), user_profile_pb2.UNKNOWN_GENDER) self.save_new_user(user, auth_request.user_data) response.auth_token = token.create_token( response.authenticated_user.user_id, 'auth') return response
def test_compound_name(self) -> None: """First name is a compound.""" self.assertEqual('Marie-Laure', french.cleanup_firstname('marie-laure')) self.assertEqual('Marie Laure', french.cleanup_firstname('marie laure')) self.assertEqual('Marie Laure', french.cleanup_firstname('marie.laure')) self.assertEqual('Marie Laure', french.cleanup_firstname('marie laure'))
def get_default_vars(user: user_pb2.User, **unused_kwargs: Any) -> dict[str, Any]: """Compute default variables used in all emails: firstName, gender and unsubscribeLink.""" return { 'areEmailsAnswerRead': product.bob.are_email_answers_read, 'baseUrl': product.bob.base_url, 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_profile_pb2.Gender.Name(user.profile.gender), 'highlightColor': product.bob.get_config('highlightColor', '#faf453'), 'productLogoUrl': product.bob.get_config( 'productLogoUrl', 'https://t.bob-emploi.fr/tplimg/6u2u/b/oirn/2ugx1.png'), 'productName': product.bob.name, 'unsubscribeLink': get_bob_link( 'unsubscribe.html', { 'auth': auth_token.create_token(user.profile.email, role='unsubscribe'), 'hl': user.profile.locale, 'user': user.user_id, }), }
def get_default_coaching_email_vars(user: user_pb2.User, **unused_kwargs: Any) -> dict[str, Any]: """Compute default variables used in all coaching emails.""" return get_default_vars(user) | { 'changeEmailSettingsUrl': get_bob_link( 'unsubscribe.html', { 'auth': auth_token.create_token(user.user_id, role='settings'), 'coachingEmailFrequency': email_pb2.EmailFrequency.Name( user.profile.coaching_email_frequency), 'hl': user.profile.locale, 'user': user.user_id, }), 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_profile_pb2.Gender.Name(user.profile.gender), # TODO(pascal): Harmonize use of URL suffix (instead of link). 'statusUpdateUrl': get_status_update_link(user), }
def get_default_vars(user: user_pb2.User, **unused_kwargs: Any) -> Dict[str, str]: """Compute default variables used in all emails: firstName, gender and unsubscribeLink.""" unsubscribe_token = parse.quote(auth.create_token(user.profile.email, role='unsubscribe')) return { 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_pb2.Gender.Name(user.profile.gender), 'unsubscribeLink': f'{BASE_URL}/unsubscribe.html?user={parse.quote(user.user_id)}&' f'auth={unsubscribe_token}&hl={parse.quote(user.profile.locale)}', }
def employment_vars(user, unused_db=None): """Compute vars for a given user for the employment survey. Returns: a dict with all vars required for the template, or None if no email should be sent. """ registered_months_ago = campaign.get_french_months_ago( user.registered_at.ToDatetime()) if not registered_months_ago: logging.warning('User registered only recently (%s)', user.registered_at) return None # If the users have already updated their employment status less than one month ago, # ignore them. for status in user.employment_status: if status.created_at.ToDatetime() > _ONE_MONTH_AGO: return None survey_token = parse.quote( auth.create_token(user.user_id, role='employment-status')) unsubscribe_token = parse.quote( auth.create_token(user.profile.email, role='unsubscribe')) return { 'firstName': french.cleanup_firstname(user.profile.name), 'registeredMonthsAgo': registered_months_ago, 'seekingUrl': '{}/api/employment-status?user={}&token={}&seeking={}&redirect={}'. format( campaign.BASE_URL, user.user_id, survey_token, 'STILL_SEEKING', parse.quote('{}/statut/en-recherche'.format(campaign.BASE_URL)), ), 'stopSeekingUrl': '{}/api/employment-status?user={}&token={}&seeking={}&redirect={}'. format( campaign.BASE_URL, user.user_id, survey_token, 'STOP_SEEKING', parse.quote('{}/statut/ne-recherche-plus'.format( campaign.BASE_URL)), ), 'unsubscribeLink': '{}/unsubscribe.html?email={}&auth={}'.format( campaign.BASE_URL, parse.quote(user.profile.email), unsubscribe_token), }
def _get_nps_vars(user: user_pb2.User, **unused_kwargs: Any) -> Optional[Dict[str, str]]: user_id = user.user_id nps_form_url = f'{campaign.BASE_URL}/retours?hl={parse.quote(user.profile.locale)}' return { 'baseUrl': campaign.BASE_URL, 'firstName': french.cleanup_firstname(user.profile.name), 'npsFormUrl': f'{campaign.BASE_URL}/api/nps?user={user_id}&token={auth.create_token(user_id, "nps")}&' f'redirect={parse.quote(nps_form_url)}', }
def body_language_vars(user, unused_db=None): """Compute vars for a given user for the body language email. Returns: a dict with all vars required for the template, or None if no email should be sent. """ if not user.projects: logging.info('User has no project') return None registered_months_ago = campaign.get_french_months_ago( user.registered_at.ToDatetime()) if not registered_months_ago: logging.info('User registered only recently (%s)', user.registered_at) return None has_read_last_focus_email = any(email.status in _READ_EMAIL_STATUSES for email in user.emails_sent if email.campaign_id.startswith('focus-')) worst_frustration = next( (user_pb2.Frustration.Name(frustration) for frustration in (user_pb2.SELF_CONFIDENCE, user_pb2.INTERVIEW, user_pb2.ATYPIC_PROFILE) if frustration in user.profile.frustrations), '') if not worst_frustration: return None unsubscribe_token = parse.quote( auth.create_token(user.profile.email, role='unsubscribe')) return { 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_pb2.Gender.Name(user.profile.gender), 'hasReadLastFocusEmail': campaign.as_template_boolean(has_read_last_focus_email), 'registeredMonthsAgo': registered_months_ago, 'unsubscribeLink': '{}/unsubscribe.html?email={}&auth={}'.format( campaign.BASE_URL, parse.quote(user.profile.email), unsubscribe_token), 'worstFrustration': worst_frustration, }
def get_default_coaching_email_vars(user: user_pb2.User, **unused_kwargs: Any) -> Dict[str, str]: """Compute default variables used in all coaching emails.""" settings_token = parse.quote(auth.create_token(user.user_id, role='settings')) return dict(get_default_vars(user), **{ 'changeEmailSettingsUrl': f'{BASE_URL}/unsubscribe.html?user={parse.quote(user.user_id)}&auth={settings_token}&' 'coachingEmailFrequency=' + user_pb2.EmailFrequency.Name(user.profile.coaching_email_frequency) + f'&hl={parse.quote(user.profile.locale)}', 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_pb2.Gender.Name(user.profile.gender), # TODO(pascal): Harmonize use of URL suffix (instead of link). 'statusUpdateUrl': get_status_update_link(user.user_id, user.profile), })
def new_diagnostic_vars(user, unused_db=None): """Compute vars for the "New Diagnostic".""" unsubscribe_token = parse.quote( auth.create_token(user.profile.email, role='unsubscribe')) frustrations_vars = { 'frustration_{}'.format(user_pb2.Frustration.Name(f)): 'True' for f in user.profile.frustrations } age = datetime.date.today().year - user.profile.year_of_birth has_children = user.profile.family_situation in { user_pb2.FAMILY_WITH_KIDS, user_pb2.SINGLE_PARENT_SITUATION, } survey_token = parse.quote( auth.create_token(user.user_id, role='employment-status')) auth_token = parse.quote( auth.create_token(user.user_id, is_using_timestamp=True)) return dict( frustrations_vars, **{ 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_pb2.Gender.Name(user.profile.gender), 'mayHaveSeekingChildren': campaign.as_template_boolean(has_children and age >= 45), 'loginUrl': '{}?userId={}&authToken={}'.format(campaign.BASE_URL, user.user_id, auth_token), 'stopSeekingUrl': '{}/api/employment-status?user={}&token={}&seeking={}&redirect={}'. format( campaign.BASE_URL, user.user_id, survey_token, 'STOP_SEEKING', parse.quote('{}/statut/ne-recherche-plus'.format( campaign.BASE_URL)), ), 'unsubscribeLink': '{}/unsubscribe.html?email={}&auth={}'.format( campaign.BASE_URL, parse.quote(user.profile.email), unsubscribe_token), })
def send_email_to_user(user, user_id, base_url): """Sends an email to the user to measure the Net Promoter Score.""" # Renew actions for the day if needed. mail_result = mail.send_template( _MAILJET_TEMPLATE_ID, user.profile, { 'baseUrl': base_url, 'firstName': french.cleanup_firstname(user.profile.name), 'npsFormUrl': '{}/api/nps?user={}&token={}&redirect={}'.format( base_url, user_id, auth.create_token(user_id, 'nps'), parse.quote('{}/retours'.format(base_url)), ), }, dry_run=DRY_RUN, ) mail_result.raise_for_status() return mail_result
def spontaneous_vars(user, previous_email_campaign_id): """Compute vars for a given user for the spontaneous email. Returns: a dict with all vars required for the template, or None if no email should be sent. """ if not user.projects: logging.info('User has no project') return None project = user.projects[0] job_group_info = jobs.get_group_proto(_DB, project.target_job.job_group.rome_id) def _should_use_spontaneous(modes): return any(mode.mode == job_pb2.SPONTANEOUS_APPLICATION and mode.percentage > 20 for mode in modes.modes) application_modes = job_group_info.application_modes if not any( _should_use_spontaneous(modes) for modes in application_modes.values()): return None registered_months_ago = campaign.get_french_months_ago( user.registered_at.ToDatetime()) if not registered_months_ago: logging.warning('User registered only recently (%s)', user.registered_at) return None has_read_previous_email = previous_email_campaign_id and any( email.campaign_id == previous_email_campaign_id and email.status in (user_pb2.EMAIL_SENT_OPENED, user_pb2.EMAIL_SENT_CLICKED) for email in user.emails_sent) contact_mode = job_group_info.preferred_application_medium if not contact_mode: logging.error('There is no contact mode for the job group "%s"', project.target_job.job_group.rome_id) return None contact_mode = job_pb2.ApplicationMedium.Name(contact_mode).replace( 'APPLY_', '') in_a_workplace = job_group_info.in_a_workplace if not in_a_workplace and contact_mode != 'BY_EMAIL': logging.error( 'There is no "in_a_workplace" field for the job group "%s".', project.target_job.job_group.rome_id) return None like_your_workplace = job_group_info.like_your_workplace if in_a_workplace and not like_your_workplace: logging.error( 'There is no "like_your_workplace" field for the job group "%s".', project.target_job.job_group.rome_id) return None to_the_workplace = job_group_info.to_the_workplace if not to_the_workplace: to_the_workplace = "à l'entreprise" some_companies = job_group_info.place_plural if not some_companies: some_companies = 'des entreprises' what_i_love_about = job_group_info.what_i_love_about if user.profile.gender == user_pb2.FEMININE: what_i_love_about_feminine = job_group_info.what_i_love_about_feminine if what_i_love_about_feminine: what_i_love_about = what_i_love_about_feminine if not what_i_love_about and contact_mode == 'BY_EMAIL': logging.error( 'There is no "What I love about" field for the job group "%s".', project.target_job.job_group.rome_id) return None why_specific_company = job_group_info.why_specific_company if not why_specific_company: logging.error( 'There is no "Why this specific company" field for the job group "%s".', project.target_job.job_group.rome_id) return None at_various_companies = job_group_info.at_various_companies if project.weekly_applications_estimate == project_pb2.SOME: weekly_application_count = '5' elif project.weekly_applications_estimate > project_pb2.SOME: weekly_application_count = '15' else: weekly_application_count = '' survey_token = parse.quote( auth.create_token(user.user_id, role='employment-status')) unsubscribe_token = parse.quote( auth.create_token(user.profile.email, role='unsubscribe')) return { 'applicationComplexity': job_pb2.ApplicationProcessComplexity.Name( job_group_info.application_complexity), 'atVariousCompanies': at_various_companies, 'contactMode': contact_mode, 'deepLinkLBB': 'https://labonneboite.pole-emploi.fr/entreprises/commune/{}/rome/' '{}?utm_medium=web&utm_source=bob&utm_campaign=bob-email'.format( project.mobility.city.city_id, project.target_job.job_group.rome_id), 'emailInUrl': parse.quote(user.profile.email), 'experienceAsText': _EXPERIENCE_AS_TEXT.get(project.seniority, 'peu'), 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_pb2.Gender.Name(user.profile.gender), 'hasReadPreviousEmail': campaign.as_template_boolean(has_read_previous_email), 'inWorkPlace': in_a_workplace, 'jobName': french.lower_first_letter( french.genderize_job(project.target_job, user.profile.gender)), 'lastName': user.profile.last_name, 'likeYourWorkplace': like_your_workplace, 'registeredMonthsAgo': registered_months_ago, 'someCompanies': some_companies, # TODO(cyrille): Use campaign.get_status_update_link 'statusUpdateUrl': '{}/statut/mise-a-jour?user={}&token={}&gender={}'.format( campaign.BASE_URL, user.user_id, survey_token, user_pb2.Gender.Name(user.profile.gender)), 'toTheWorkplace': to_the_workplace, 'unsubscribeLink': '{}/unsubscribe.html?email={}&auth={}'.format( campaign.BASE_URL, parse.quote(user.profile.email), unsubscribe_token), 'weeklyApplicationOptions': weekly_application_count, 'whatILoveAbout': what_i_love_about, 'whySpecificCompany': why_specific_company, }
def _pe_connect_authenticate(self, code, nonce): token_data = _get_oauth2_access_token( 'https://authentification-candidat.pole-emploi.fr/connexion/oauth2/access_token?' 'realm=/individu', code=code, client_id=_EMPLOI_STORE_CLIENT_ID, client_secret=_EMPLOI_STORE_CLIENT_SECRET, auth_name='PE Connect', ) if token_data.get('nonce') != nonce: flask.abort(403, 'Mauvais paramètre nonce') authorization_header = '{} {}'.format( token_data.get('token_type', 'Bearer'), token_data.get('access_token', '')) scopes = token_data.get('scope', '').split(' ') user_info_response = requests.get( 'https://api.emploi-store.fr/partenaire/peconnect-individu/v1/userinfo', headers={'Authorization': authorization_header}) if user_info_response.status_code < 200 or user_info_response.status_code >= 400: logging.warning('PE Connect fails (%d): "%s"', user_info_response.status_code, user_info_response.text) flask.abort(403, user_info_response.text) user_info = user_info_response.json() city = None if 'coordonnees' in scopes: coordinates_response = requests.get( 'https://api.emploi-store.fr/partenaire/peconnect-coordonnees/v1/coordonnees', headers={ 'Authorization': authorization_header, 'pe-nom-application': 'Bob Emploi', }) if coordinates_response.status_code >= 200 and coordinates_response.status_code < 400: coordinates = coordinates_response.json() city = geo.get_city_proto(coordinates.get('codeINSEE')) job = None if 'competences' in scopes: competences_response = requests.get( 'https://api.emploi-store.fr/partenaire/peconnect-competences/v1/competences', headers={'Authorization': authorization_header}, ) if competences_response.status_code >= 200 and competences_response.status_code < 400: competences = competences_response.json() job_id, rome_id = next( ((c.get('codeAppellation'), c.get('codeRome')) for c in competences), (None, None)) job = jobs.get_job_proto(self._db, job_id, rome_id) response = user_pb2.AuthResponse() user_dict = self._user_db.user.find_one( {'peConnectId': user_info['sub']}) user_id = str(user_dict.pop('_id')) if user_dict else '' if proto.parse_from_mongo(user_dict, response.authenticated_user): response.authenticated_user.user_id = user_id self._handle_returning_user(response) else: email = user_info.get('email') if email: self._assert_user_not_existing(email) response.authenticated_user.profile.email = email user = response.authenticated_user user.pe_connect_id = user_info['sub'] # TODO(pascal): Handle the case where one of the name is missing. user.profile.name = french.cleanup_firstname( user_info.get('given_name', '')) user.profile.last_name = french.cleanup_firstname( user_info.get('family_name', '')) user.profile.gender = \ _PE_CONNECT_GENDER.get(user_info.get('gender', ''), user_pb2.UNKNOWN_GENDER) if city or job: user.projects.add( is_incomplete=True, mobility=geo_pb2.Location(city=city) if city else None, target_job=job) self._save_new_user(user) response.is_new_user = True response.auth_token = create_token(response.authenticated_user.user_id, 'auth') return response
def test_extra_blanks(self) -> None: """First name contains special unneeded blanks.""" self.assertEqual('Pascal', french.cleanup_firstname('pascal ')) self.assertEqual('Pascal', french.cleanup_firstname(' pascal'))
def test_special_chars(self) -> None: """First name contains special chars.""" self.assertEqual('Éloïse', french.cleanup_firstname('éloïse')) self.assertEqual('Éloïse', french.cleanup_firstname('ÉLOÏSE'))
def _pe_connect_authenticate( self, auth_request: auth_pb2.AuthRequest) -> auth_pb2.AuthResponse: token_data = _get_oauth2_access_token( 'https://authentification-candidat.pole-emploi.fr/connexion/oauth2/access_token?' 'realm=/individu', code=auth_request.pe_connect_code, client_id=_EMPLOI_STORE_CLIENT_ID or '', client_secret=_EMPLOI_STORE_CLIENT_SECRET or '', auth_name='PE Connect', ) if token_data.get('nonce') != auth_request.pe_connect_nonce: flask.abort(403, 'Mauvais paramètre nonce') bearer = token_data.get('token_type', 'Bearer') access_token = token_data.get('access_token', '') authorization_header = f'{bearer} {access_token}' scopes = token_data.get('scope', '').split(' ') user_info_response = requests.get( 'https://api.emploi-store.fr/partenaire/peconnect-individu/v1/userinfo', headers={'Authorization': authorization_header}) if user_info_response.status_code < 200 or user_info_response.status_code >= 400: logging.warning('PE Connect fails (%d): "%s"', user_info_response.status_code, user_info_response.text) flask.abort(403, user_info_response.text) user_info = typing.cast(Dict[str, str], user_info_response.json()) city = None if 'coordonnees' in scopes: coordinates_response = requests.get( 'https://api.emploi-store.fr/partenaire/peconnect-coordonnees/v1/coordonnees', headers={ 'Authorization': authorization_header, 'pe-nom-application': 'Bob Emploi', }) if coordinates_response.status_code >= 200 and coordinates_response.status_code < 400: coordinates = typing.cast(Dict[str, str], coordinates_response.json()) code_insee = coordinates.get('codeINSEE') if code_insee: clean_code_insee = _replace_arrondissement_insee_to_city( code_insee) city = geo.get_city_proto(clean_code_insee) job = None if 'competences' in scopes: competences_response = requests.get( 'https://api.emploi-store.fr/partenaire/peconnect-competences/v1/competences', headers={'Authorization': authorization_header}, ) if competences_response.status_code >= 200 and competences_response.status_code < 400: competences = typing.cast(List[Dict[str, str]], competences_response.json()) job_id, rome_id = next( ((c.get('codeAppellation'), c.get('codeRome')) for c in competences), (None, None)) if job_id and rome_id: job = jobs.get_job_proto(self._db, job_id, rome_id) response = auth_pb2.AuthResponse() user_dict = self._user_collection.find_one( {'peConnectId': user_info['sub']}) if proto.parse_from_mongo(user_dict, response.authenticated_user, 'user_id'): self._handle_returning_user(response) else: user = response.authenticated_user is_existing_user, had_email = self._load_user_from_token_or_email( auth_request, user, user_info.get('email')) user.pe_connect_id = user_info['sub'] response.is_new_user = force_update = not user.has_account user.has_account = True if city or job and not user.projects: force_update = True user.projects.add(is_incomplete=True, city=city or None, target_job=job) if is_existing_user: self._handle_returning_user(response, force_update=force_update, had_email=had_email) else: # TODO(pascal): Handle the case where one of the name is missing. user.profile.name = french.cleanup_firstname( user_info.get('given_name', '')) user.profile.last_name = french.cleanup_firstname( user_info.get('family_name', '')) user.profile.gender = \ _PE_CONNECT_GENDER.get(user_info.get('gender', ''), user_pb2.UNKNOWN_GENDER) self.save_new_user(user, auth_request.user_data) response.auth_token = create_token(response.authenticated_user.user_id, 'auth') return response
def self_development_vars(user, unused_db=None): """Compute vars for a given user for the self-development email. Returns: a dict with all vars required for the template, or None if no email should be sent. """ if not user.projects: logging.info('User has no project') return None project = user.projects[0] registered_months_ago = campaign.get_french_months_ago( user.registered_at.ToDatetime()) if not registered_months_ago: logging.info('User registered only recently (%s)', user.registered_at) return None job_search_length = campaign.job_search_started_months_ago(project) if job_search_length < 0: logging.info('No info on user search duration') return None if job_search_length >= 12: logging.info('User has been searching for too long (%s)', job_search_length) return None genderized_job_name = french.lower_first_letter( french.genderize_job(project.target_job, user.profile.gender)) age = datetime.date.today().year - user.profile.year_of_birth unsubscribe_token = parse.quote( auth.create_token(user.profile.email, role='unsubscribe')) max_young = 30 min_old = 50 return { 'firstName': french.cleanup_firstname(user.profile.name), 'gender': user_pb2.Gender.Name(user.profile.gender), 'hasEnoughExperience': campaign.as_template_boolean(project.seniority > project_pb2.JUNIOR), 'isAdministrativeAssistant': campaign.as_template_boolean( project.target_job.job_group.name == 'Secrétariat'), 'isOld': campaign.as_template_boolean(age >= min_old), 'isOldNotWoman': campaign.as_template_boolean( age >= min_old and user.profile.gender != user_pb2.FEMININE), 'isYoung': campaign.as_template_boolean(age <= max_young), 'isYoungNotWoman': campaign.as_template_boolean( age <= max_young and user.profile.gender != user_pb2.FEMININE), 'jobName': genderized_job_name, 'ofJobName': french.maybe_contract_prefix('de ', "d'", genderized_job_name), 'registeredMonthsAgo': registered_months_ago, 'unsubscribeLink': '{}/unsubscribe.html?email={}&auth={}'.format( campaign.BASE_URL, parse.quote(user.profile.email), unsubscribe_token), }