def test_returned_old_user(self, mock_info: mock.MagicMock) -> None: """An inactive user back on Bob doesn't get deleted.""" self._db.user.insert_one({ 'hasAccount': True, 'profile': {'name': 'Sil'}, 'registeredAt': '2014-07-03T00:00:00Z', 'emailsSent': [{ 'campaignId': 'whatever-email', 'sentAt': proto.datetime_to_json_string( datetime.datetime.now() - datetime.timedelta(days=12)) }], 'requestedByUserAtDate': proto.datetime_to_json_string( datetime.datetime.now() - datetime.timedelta(days=10)) }) clean_users.main(['--no-dry-run', '--disable-sentry']) mock_info.assert_called_once_with( 'Cleaned %d users, set check date for %d users and got %d errors', 0, 1, 0) db_user = self._db.user.find_one({}) assert db_user req_user_by_date = _zulu_time_to_datetime(db_user.get('requestedByUserAtDate')) chck_for_deletion_date = _zulu_time_to_datetime(db_user.get('checkForDeletionDate')) self.assertEqual('Sil', db_user.get('profile', {}).get('name')) self.assertFalse(db_user.get('deletedAt')) self.assertEqual( req_user_by_date.replace(microsecond=0) + datetime.timedelta(days=90), chck_for_deletion_date.replace(microsecond=0))
def main(string_args: Optional[list[str]] = None) -> None: """Clean all support tickets marked for deletion.""" user_db = mongo.get_connections_from_env().user_db parser = argparse.ArgumentParser( description='Clean support tickets from the database.') report.add_report_arguments(parser) args = parser.parse_args(string_args) if not report.setup_sentry_logging(args): return instant = proto.datetime_to_json_string(now.get()) result = user_db.user.update_many( {}, {'$pull': { 'supportTickets': { 'deleteAfter': { '$lt': instant } } }}) logging.info('Removed deprecated support tickets for %d users.', result.modified_count) clean_result = user_db.user.update_many({'supportTickets': { '$size': 0 }}, {'$unset': { 'supportTickets': '' }}) if clean_result.matched_count: logging.info('Removed empty support ticket list for %d users.', clean_result.modified_count)
def _send_focus_emails( action: 'campaign.NoGhostAction', dry_run_email: str, restricted_campaigns: Optional[Iterable[mailjet_templates.Id]] = None) -> None: database, users_database, eval_database = mongo.get_connections_from_env() instant = now.get() email_errors = 0 counts = { campaign_id: 0 for campaign_id in sorted(get_possible_campaigns(database, restricted_campaigns)) } potential_users = users_database.user.find({ 'profile.email': { '$regex': re.compile(r'[^ ]+@[^ ]+\.[^ ]+'), '$not': re.compile(r'@example.com$'), }, 'projects': {'$elemMatch': { 'isIncomplete': {'$ne': True}, }}, 'profile.coachingEmailFrequency': {'$in': [ email_pb2.EmailFrequency.Name(setting) for setting in _EMAIL_PERIOD_DAYS]}, # Note that "not >" is not equivalent to "<=" in the case the field # is not defined: in that case we do want to select the user. 'sendCoachingEmailAfter': {'$not': {'$gt': proto.datetime_to_json_string(instant)}}, }) restricted_campaigns_set: Optional[Set[mailjet_templates.Id]] if restricted_campaigns: restricted_campaigns_set = set(restricted_campaigns) else: restricted_campaigns_set = None for user_dict in potential_users: user_id = user_dict.pop('_id') user = proto.create_from_mongo(user_dict, user_pb2.User) user.user_id = str(user_id) try: campaign_id = send_focus_email_to_user( action, user, dry_run_email=dry_run_email, database=database, users_database=users_database, eval_database=eval_database, instant=instant, restricted_campaigns=restricted_campaigns_set) except requests.exceptions.HTTPError as error: if action == 'dry-run': raise logging.warning('Error while sending an email: %s', error) email_errors += 1 continue if campaign_id: counts[campaign_id] += 1 if action == 'dry-run': break continue report_message = 'Focus emails sent today:\n' + '\n'.join([ f' • *{campaign_id}*: {count} email{"s" if count > 1 else ""}' for campaign_id, count in counts.items() ]) if action == 'send': report.notify_slack(report_message) logging.info(report_message)
def main(string_args: Optional[List[str]] = None) -> None: """Time out CVS and motivation letters reviews.""" parser = argparse.ArgumentParser( description='Time out CVs and motivation letters reviews.', formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('--days-before-timeout', default='5', type=int) args = parser.parse_args(string_args) timeout_date = now.get() - datetime.timedelta( days=args.days_before_timeout) documents = _USER_DB.cvs_and_cover_letters.find({ 'reviews': { '$elemMatch': { 'sentAt': { '$lt': proto.datetime_to_json_string(timeout_date) }, 'status': 'REVIEW_SENT', } }, }) for document in documents: _timeout_old_reviews(document, timeout_date)
def _log_request(email: str, requester_email: str, database: mongo.NoPiiMongoDatabase) -> None: try: # Log that we've tried to access to a specific user. database.email_requests.insert_one({ 'email': email, 'registeredAt': proto.datetime_to_json_string(now.get()), 'requesterEmail': requester_email, 'action': 'eval', }) except errors.OperationFailure: flask.abort(401, "Vous n'avez pas accès en écriture à la base de données.")
def test_create_support_ticket(self) -> None: """A user is assigned a support ID if requested.""" user_id, token = self.create_user_with_token() response = self.app.post(f'/api/support/{user_id}', headers={'Authorization': 'Bearer ' + token}, content_type='application/json') ticket = self.json_from_response(response) self.assertTrue(ticket.get('ticketId')) delete_after = ticket.get('deleteAfter') do_not_delete_before = proto.datetime_to_json_string( now.get() + datetime.timedelta(days=1)) delete_before = proto.datetime_to_json_string(now.get() + datetime.timedelta( days=30)) self.assertGreater(delete_after, do_not_delete_before) self.assertLess(delete_after, delete_before) user_data = self.get_user_info(user_id, token) last_saved_ticket = typing.cast( Dict[str, str], user_data.get('supportTickets', [])[-1]) self.assertEqual(ticket, last_saved_ticket)
def test_days_ago(self, mock_info: mock.MagicMock) -> None: """Only guests users connected before the 1 week period are deleted.""" self._db.user.insert_many([{ 'profile': {'name': 'Cyrille'}, 'projects': [{'projectId': str(i)}], 'requestedByUserAtDate': proto.datetime_to_json_string( datetime.datetime.now() - datetime.timedelta(days=i, hours=1)), } for i in range(4, 11)]) clean_users.main(['--no-dry-run', '--disable-sentry']) mock_info.assert_called_once_with( 'Cleaned %d users, set check date for %d users and got %d errors', 4, 3, 0) self.assertCountEqual( ['4', '5', '6'], [user['projects'][0]['projectId'] for user in self._db.user.find({'deletedAt': None})])
def _send_focus_emails(action: 'campaign.Action', dry_run_email: str) -> None: database, users_database, unused_eval_database = mongo.get_connections_from_env() instant = now.get() email_errors = 0 counts = {campaign_id: 0 for campaign_id in _FOCUS_CAMPAIGNS} potential_users = users_database.user.find({ 'profile.email': re.compile('.+@.+'), 'projects': {'$elemMatch': { 'isIncomplete': {'$ne': True}, }}, 'profile.coachingEmailFrequency': {'$in': [ user_pb2.EmailFrequency.Name(setting) for setting in _EMAIL_PERIOD_DAYS]}, # Note that "not >" is not equivalent to "<=" in the case the field # is not defined: in that case we do want to select the user. 'sendCoachingEmailAfter': {'$not': {'$gt': proto.datetime_to_json_string(instant)}}, }) for user_dict in potential_users: user_id = user_dict.pop('_id') user = typing.cast(user_pb2.User, proto.create_from_mongo(user_dict, user_pb2.User)) user.user_id = str(user_id) try: campaign_id = send_focus_email_to_user( action, user, dry_run_email=dry_run_email, database=database, users_database=users_database, instant=instant) except requests.exceptions.HTTPError as error: if action == 'dry-run': raise logging.warning('Error while sending an email: %s', error) email_errors += 1 continue if campaign_id: counts[campaign_id] += 1 continue report_message = 'Focus emails sent:\n' + '\n'.join([ f' • *{campaign_id}*: {count} email{"s" if count > 1 else ""}' for campaign_id, count in counts.items() ]) if action == 'send': report.notify_slack(report_message) logging.info(report_message)
def test_recent_email_reader(self, mock_info: mock.MagicMock) -> None: """A user that has read an email in the pas 2 months doesn't get deleted.""" self._db.user.insert_one({ 'hasAccount': True, 'profile': {'name': 'Sil'}, 'registeredAt': '2014-07-03T00:00:00Z', 'emailsSent': [{ 'campaignId': 'christmas', 'sentAt': proto.datetime_to_json_string( datetime.datetime.now() - datetime.timedelta(days=79)), 'status': email_pb2.EMAIL_SENT_OPENED }], 'requestedByUserAtDate': '2014-07-10T00:00:00Z' }) clean_users.main(['--no-dry-run', '--disable-sentry']) mock_info.assert_called_once_with( 'Cleaned %d users, set check date for %d users and got %d errors', 0, 1, 0) db_user = self._db.user.find_one({}) assert db_user self.assertFalse(db_user.get('deletedAt'))
def test_old_user(self, mock_info: mock.MagicMock) -> None: """An inactive user gets deleted.""" self._db.user.insert_one({ 'hasAccount': True, 'profile': {'name': 'Sil'}, 'registeredAt': '2014-07-03T00:00:00Z', 'emailsSent': [{ 'campaignId': 'account-deletion-notice', 'sentAt': proto.datetime_to_json_string( datetime.datetime.today() - datetime.timedelta(days=9)) }], 'requestedByUserAtDate': '2014-07-10T00:00:00Z' }) clean_users.main(['--no-dry-run', '--disable-sentry']) mock_info.assert_called_once_with( 'Cleaned %d users, set check date for %d users and got %d errors', 1, 0, 0) db_user = self._db.user.find_one({}) assert db_user self.assertEqual('REDACTED', db_user.get('profile', {}).get('name')) self.assertTrue(db_user.get('deletedAt'))
def test_days_ago(self, mock_info: mock.MagicMock) -> None: """Only users created before the given days delta are deleted.""" self._db.user.insert_many([{ 'profile': { 'name': 'Cyrille' }, 'projects': [{ 'projectId': str(i) }], 'registeredAt': proto.datetime_to_json_string(datetime.datetime.now() - datetime.timedelta(days=i, hours=1)), } for i in range(7)]) clean_guests.main( ['--no-dry-run', '--disable-sentry', '--registered-to-days-ago=3']) mock_info.assert_called_once_with('Cleaned %d users and got %d errors', 4, 0) self.assertCountEqual(['0', '1', '2'], [ user['projects'][0]['projectId'] for user in self._db.user.find({'deletedAt': None}) ])
def main(string_args: Optional[List[str]] = None) -> None: """Clean all support tickets marked for deletion.""" parser = argparse.ArgumentParser( description='Clean support tickets from the database.') parser.add_argument('--disable-sentry', action='store_true', help='Disable logging to Sentry.') args = parser.parse_args(string_args) logging.basicConfig(level='INFO') if not args.disable_sentry: try: report.setup_sentry_logging(os.getenv('SENTRY_DSN')) except ValueError: logging.error( 'Please set SENTRY_DSN to enable logging to Sentry, or use --disable-sentry option' ) return instant = proto.datetime_to_json_string(now.get()) result = _DB.user.update_many( {}, {'$pull': { 'supportTickets': { 'deleteAfter': { '$lt': instant } } }}) logging.info('Removed deprecated support tickets for %d users.', result.modified_count) clean_result = _DB.user.update_many({'supportTickets': { '$size': 0 }}, {'$unset': { 'supportTickets': '' }}) if clean_result.matched_count: logging.info('Removed empty support ticket list for %d users.', clean_result.modified_count)
from typing import Optional, Tuple import bson from bson import objectid import pymongo from bob_emploi.frontend.api import user_pb2 from bob_emploi.frontend.server import auth from bob_emploi.frontend.server import mongo from bob_emploi.frontend.server import proto from bob_emploi.frontend.server.asynchronous import report from bob_emploi.frontend.server.mail import mail_send _MAX_GUEST_IDLE_TIME = datetime.timedelta(7) _MAX_SIGNED_IN_USER_IDLE_TIME = datetime.timedelta(90) _TODAY_STRING = proto.datetime_to_json_string(datetime.datetime.now()) def _get_last_interaction_date(user_proto: user_pb2.User) -> datetime.datetime: """Get the date of user's last email or app interaction.""" last_interaction_date = user_proto.registered_at.ToDatetime() if user_proto.registered_at: last_interaction_date = user_proto.requested_by_user_at_date.ToDatetime( ) email_interaction_dates = [ email.sent_at.ToDatetime() for email in user_proto.emails_sent if email.campaign_id != 'account-deletion-notice' and email.status in mail_send.READ_EMAIL_STATUSES and email.sent_at ]
"""Campaigns for the account deletion emails.""" import datetime from typing import Any from bob_emploi.frontend.api import user_pb2 from bob_emploi.frontend.server import i18n from bob_emploi.frontend.server import proto from bob_emploi.frontend.server.mail import campaign from bob_emploi.frontend.server.mail import mail_send _TWO_YEARS_AGO_STRING = \ proto.datetime_to_json_string(datetime.datetime.now() - datetime.timedelta(730)) def _account_deletion_notice_vars(user: user_pb2.User, **unused_kwargs: Any) -> dict[str, str]: return dict( campaign.get_default_vars(user), loginUrl=campaign.create_logged_url(user.user_id)) campaign.register_campaign(campaign.Campaign( campaign_id='account-deletion-notice', mongo_filters={ # User hasn't been on Bob for two years. 'registeredAt': {'$lt': _TWO_YEARS_AGO_STRING}, 'requestedByUserAtDate': {'$not': {'$gt': _TWO_YEARS_AGO_STRING}}, # User hasn't read any email we sent to them in the last two years. 'emailsSent': {'$not': {'$elemMatch': { 'sentAt': {'$gt': _TWO_YEARS_AGO_STRING}, 'status': {'$in': list(mail_send.READ_EMAIL_STATUS_STRINGS)},
def send_focus_email_to_user( action: 'campaign.Action', user: user_pb2.User, *, dry_run_email: Optional[str] = None, database: mongo.NoPiiMongoDatabase, users_database: Optional[mongo.UsersDatabase] = None, eval_database: Optional[mongo.NoPiiMongoDatabase] = None, instant: datetime.datetime, restricted_campaigns: Optional[Set[mailjet_templates.Id]] = None) \ -> Optional[mailjet_templates.Id]: """Try to send a focus email to the user and returns the campaign ID.""" if user.profile.coaching_email_frequency == email_pb2.EMAIL_NONE: return None if not user.HasField('send_coaching_email_after'): send_coaching_email_after = _compute_next_coaching_email_date(user) if send_coaching_email_after > instant: user.send_coaching_email_after.FromDatetime(send_coaching_email_after) if user.user_id and action != 'ghost' and users_database: users_database.user.update_one( {'_id': objectid.ObjectId(user.user_id)}, {'$set': { 'sendCoachingEmailAfter': proto.datetime_to_json_string( send_coaching_email_after, ), }}) return None # Compute next send_coaching_email_after. next_send_coaching_email_after = instant + _compute_duration_to_next_coaching_email(user) focus_emails_sent: Set[mailjet_templates.Id] = set() last_focus_email_sent = None for email_sent in user.emails_sent: if email_sent.campaign_id not in _FOCUS_CAMPAIGNS: continue last_focus_email_sent = email_sent focus_emails_sent.add(typing.cast(mailjet_templates.Id, email_sent.campaign_id)) project = scoring.ScoringProject( user.projects[0] if user.projects else project_pb2.Project(), user, database, instant, ) possible_campaigns = get_possible_campaigns(database, restricted_campaigns) campaigns_scores = { campaign_id: ( project.score(possible_campaign.scoring_model) if possible_campaign.scoring_model else 2 ) for campaign_id, possible_campaign in possible_campaigns.items() } focus_emails_project_score_zero = { campaign for campaign, score in campaigns_scores.items() if not score } potential_campaigns = _shuffle( possible_campaigns.keys() - focus_emails_sent - focus_emails_project_score_zero, last_focus_email_sent, campaigns_scores, user.profile.coaching_email_frequency) for campaign_id in potential_campaigns: if _FOCUS_CAMPAIGNS[campaign_id].send_mail( user, database=database, users_database=users_database, eval_database=eval_database, action=action, dry_run_email=dry_run_email, mongo_user_update={'$set': { 'sendCoachingEmailAfter': proto.datetime_to_json_string( next_send_coaching_email_after, ), }}, now=instant): user.send_coaching_email_after.FromDatetime(next_send_coaching_email_after) return campaign_id # No focus email was supported: it seems that we have sent all the # ones we had. However maybe in the future we'll add more focus # emails so let's wait the same amount of time we have waited until # this email (this makes to wait 1 period, then 2, 4, …). last_coaching_email_sent_at = \ _compute_last_coaching_email_date(user, user.registered_at.ToDatetime()) send_coaching_email_after = instant + (instant - last_coaching_email_sent_at) user.send_coaching_email_after.FromDatetime(send_coaching_email_after) if user.user_id and action != 'ghost' and users_database: logging.debug('No more available focus email for "%s"', user.user_id) users_database.user.update_one({'_id': objectid.ObjectId(user.user_id)}, {'$set': { 'sendCoachingEmailAfter': proto.datetime_to_json_string(send_coaching_email_after), }}) return None
def main(string_args: Optional[list[str]] = None) -> None: """Check the status of sent emails on MailJet and update our Database. """ parser = argparse.ArgumentParser( description='Update email status on sent emails.', formatter_class=argparse.ArgumentDefaultsHelpFormatter) report.add_report_arguments(parser) parser.add_argument( '--campaigns', choices=mail_blast.campaign.list_all_campaigns(), nargs='*', help='Campaign IDs to check. If not specified, run for all campaigns.') parser.add_argument('--mongo-collection', default='user', help='Name of the mongo collection to update.') args = parser.parse_args(string_args) if not report.setup_sentry_logging(args): return email_mongo_filter = { 'mailjetMessageId': { '$exists': True }, } if args.campaigns: email_mongo_filter['campaignId'] = {'$in': args.campaigns} yesterday = proto.datetime_to_json_string(now.get() - datetime.timedelta(days=1)) mongo_filter = { '$or': [ # Emails that we've never checked. { 'emailsSent': { '$elemMatch': dict({ 'lastStatusCheckedAt': { '$exists': False }, }, **email_mongo_filter), }, }, # Emails checked less than two weeks after they have been sent and # that we haven't checked today. { 'emailsSent': { '$elemMatch': dict( { 'lastStatusCheckedAt': { '$lt': yesterday }, 'lastStatusCheckedAfterDays': { '$not': { '$gte': 14 } }, }, **email_mongo_filter), }, }, # Emails sent less than 24 hours ago. { 'emailsSent': { '$elemMatch': dict({ 'sentAt': { '$gt': yesterday }, }, **email_mongo_filter), }, }, ], } user_db = mongo.get_connections_from_env().user_db mongo_collection = user_db.get_collection(args.mongo_collection) selected_users = mongo_collection.find(mongo_filter, {'emailsSent': 1}) treated_users = 0 # TODO(cyrille): Make sure errors are logged to sentry. # TODO(cyrille): If it fails on a specific user, keep going. for user in selected_users: emails_sent = user.get('emailsSent', []) updated_emails_sent = [ _update_email_sent_status(email, yesterday, campaign_ids=args.campaigns) for email in emails_sent ] mongo_collection.update_one( {'_id': user['_id']}, {'$set': { 'emailsSent': updated_emails_sent }}) treated_users += 1 if not treated_users % 100: logging.info('Treated %d users', treated_users)
def send_mail( self, user: user_pb2.User, *, database: mongo.NoPiiMongoDatabase, users_database: Optional[mongo.UsersDatabase] = None, eval_database: Optional[mongo.NoPiiMongoDatabase] = None, now: datetime.datetime, action: 'Action' = 'dry-run', dry_run_email: Optional[str] = None, mongo_user_update: Optional[dict[str, Any]] = None, should_log_errors: bool = False ) -> Union[bool, email_pb2.EmailSent]: """Send an email for this campaign.""" with set_time_locale(_LOCALE_MAP.get(user.profile.locale or 'fr')): template_vars = self.get_vars(user, should_log_errors=should_log_errors, database=database, now=now) if not template_vars: return False user_profile = user.profile if action == 'list': user_id = user.user_id logging.info('%s: %s %s', self.id, user_id, user_profile.email) return True if action == 'dry-run': user_profile.email = dry_run_email or '*****@*****.**' logging.info('Template vars:\n%s', template_vars) if action == 'ghost': email_sent = user.emails_sent.add() common_proto.set_date_now(email_sent.sent_at, now) else: res = mail_send.send_template( self.id, user_profile, template_vars, sender_email=self._sender_email, sender_name=template_vars['senderName']) logging.info( 'Email sent to %s', user_profile.email if action == 'dry-run' else user.user_id) res.raise_for_status() maybe_email_sent = mail_send.create_email_sent_proto(res) if not maybe_email_sent: logging.warning( 'Impossible to retrieve the sent email ID:\n' 'Response (%d):\n%s\nUser: %s\nCampaign: %s', res.status_code, res.json(), user.user_id, self.id) return False if action == 'dry-run': return maybe_email_sent email_sent = maybe_email_sent campaign_subject = get_campaign_subject(self.id) try: i18n_campaign_subject = i18n.translate_string( campaign_subject, user_profile.locale) except i18n.TranslationMissingException: i18n_campaign_subject = campaign_subject email_sent.subject = mustache.instantiate(i18n_campaign_subject, template_vars) email_sent.mailjet_template = str( mailjet_templates.MAP[self.id]['mailjetTemplate']) email_sent.is_coaching = self.is_coaching email_sent.campaign_id = self.id if mongo_user_update and '$push' in mongo_user_update: # pragma: no-cover raise ValueError( f'$push operations are not allowed in mongo_user_update:\n{mongo_user_update}' ) user_id = user.user_id if not user_id or action == 'ghost' or not users_database: return email_sent users_database.user.update_one( {'_id': objectid.ObjectId(user_id)}, (mongo_user_update or {}) | {'$push': { 'emailsSent': json_format.MessageToDict(email_sent), }}) if eval_database: try: eval_database.sent_emails.update_one( {'_id': self.id}, {'$set': { 'lastSent': proto.datetime_to_json_string(now) }}, upsert=True) except pymongo.errors.OperationFailure: # We ignore this error silently: it's probably due to the base not being writeable # (which is the case in our demo servers). And the whole purpose of this update is # to update the monitoring info: if it fails, then the human being checking the # monitoring data will be warned that something is wrong as the data wasn't updated. pass return email_sent
def main(string_args: Optional[List[str]] = None) -> None: """Check the status of sent emails on MailJet and update our Database. """ parser = argparse.ArgumentParser( description='Update email status on sent emails.', formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument( '--campaigns', choices=mail_blast.campaign.list_all_campaigns(), nargs='*', help='Campaign IDs to check. If not specified, run for all campaigns.') parser.add_argument('--mongo-collection', default='user', help='Name of the mongo collection to update.') parser.add_argument('--disable-sentry', action='store_true', help='Disable logging to Sentry.') args = parser.parse_args(string_args) if not args.disable_sentry: try: report.setup_sentry_logging(os.getenv('SENTRY_DSN')) except ValueError: logging.error( 'Please set SENTRY_DSN to enable logging to Sentry, or use --disable-sentry option' ) return email_mongo_filter = { 'mailjetMessageId': { '$exists': True }, } if args.campaigns: email_mongo_filter['campaignId'] = {'$in': args.campaigns} yesterday = proto.datetime_to_json_string(now.get() - datetime.timedelta(days=1)) mongo_filter = { '$or': [ # Emails that we've never checked. { 'emailsSent': { '$elemMatch': dict({ 'lastStatusCheckedAt': { '$exists': False }, }, **email_mongo_filter), }, }, # Emails checked less than two weeks after they have been sent and # that we haven't checked today. { 'emailsSent': { '$elemMatch': dict( { 'lastStatusCheckedAt': { '$lt': yesterday }, 'lastStatusCheckedAfterDays': { '$not': { '$gte': 14 } }, }, **email_mongo_filter), }, }, # Emails sent less than 24 hours ago. { 'emailsSent': { '$elemMatch': dict({ 'sentAt': { '$gt': yesterday }, }, **email_mongo_filter), }, }, ], } mongo_collection = _DB.get_collection(args.mongo_collection) selected_users = mongo_collection.find(mongo_filter, {'emailsSent': 1}) treated_users = 0 for user in selected_users: emails_sent = user.get('emailsSent', []) updated_emails_sent = [ _update_email_sent_status(email, yesterday, campaign_ids=args.campaigns) for email in emails_sent ] mongo_collection.update_one( {'_id': user['_id']}, {'$set': { 'emailsSent': updated_emails_sent }}) treated_users += 1 if not treated_users % 100: logging.info('Treated %d users', treated_users)
def send_focus_email_to_user( action: 'campaign.Action', user: user_pb2.User, *, dry_run_email: Optional[str] = None, database: pymongo.database.Database, users_database: pymongo.database.Database, instant: datetime.datetime) -> Optional[str]: """Try to send a focus email to the user and returns the campaign ID.""" if not user.HasField('send_coaching_email_after'): send_coaching_email_after = _compute_next_coaching_email_date(user) if send_coaching_email_after > instant: user.send_coaching_email_after.FromDatetime(send_coaching_email_after) if user.user_id: users_database.user.update_one( {'_id': objectid.ObjectId(user.user_id)}, {'$set': { 'sendCoachingEmailAfter': proto.datetime_to_json_string( send_coaching_email_after, ), }}) return None # Compute next send_coaching_email_after. next_send_coaching_email_after = instant + _compute_duration_to_next_coaching_email(user) focus_emails_sent = set() last_focus_email_sent = None for email_sent in user.emails_sent: if email_sent.campaign_id not in _FOCUS_CAMPAIGNS: continue last_focus_email_sent = email_sent focus_emails_sent.add(email_sent.campaign_id) last_one_was_big = last_focus_email_sent and \ _FOCUS_CAMPAIGNS[last_focus_email_sent.campaign_id].is_big_focus potential_campaigns = sorted( _POTENTIAL_CAMPAIGNS - focus_emails_sent, key=lambda c: ( 1 if _FOCUS_CAMPAIGNS[c].is_big_focus == last_one_was_big else 0, random.random(), ) ) for campaign_id in potential_campaigns: if _FOCUS_CAMPAIGNS[campaign_id].send_mail( campaign_id, user, database=database, users_database=users_database, action=action, dry_run_email=dry_run_email, mongo_user_update={'$set': { 'sendCoachingEmailAfter': proto.datetime_to_json_string( next_send_coaching_email_after, ), }}, now=instant): user.send_coaching_email_after.FromDatetime(next_send_coaching_email_after) return campaign_id # No focus email was supported: it seems that we have sent all the # ones we had. However maybe in the future we'll add more focus # emails so let's wait the same amount of time we have waited until # this email (this makes to wait 1 period, then 2, 4, …). last_coaching_email_sent_at = typing.cast( datetime.datetime, _compute_last_coaching_email_date(user, user.registered_at.ToDatetime())) send_coaching_email_after = instant + (instant - last_coaching_email_sent_at) user.send_coaching_email_after.FromDatetime(send_coaching_email_after) if user.user_id and action != 'ghost': logging.debug('No more available focus email for "%s"', user.user_id) users_database.user.update_one({'_id': objectid.ObjectId(user.user_id)}, {'$set': { 'sendCoachingEmailAfter': proto.datetime_to_json_string(send_coaching_email_after), }}) return None