Handle inactive users (#2243)
* handle inactive users Notify inactive users at 6 months and remove inactive users at 9 months * Changes suggested by @jrobbins * update comment
This commit is contained in:
Родитель
43cf1ef933
Коммит
a49ef32b30
|
@ -63,8 +63,11 @@ class AccountsAPITest(testing_config.CustomTestCase):
|
|||
|
||||
new_appuser = (user_models.AppUser.query(
|
||||
user_models.AppUser.email == 'new_user@example.com').get())
|
||||
self.assertEqual('new_user@example.com', new_appuser.email)
|
||||
self.assertFalse(new_appuser.is_admin)
|
||||
result_email = new_appuser.email
|
||||
result_is_admin = new_appuser.is_admin
|
||||
new_appuser.key.delete()
|
||||
self.assertEqual('new_user@example.com', result_email)
|
||||
self.assertFalse(result_is_admin)
|
||||
|
||||
def test_create__site_editor_valid(self):
|
||||
"""Admin wants to register a new site editor account."""
|
||||
|
|
|
@ -17,6 +17,12 @@ cron:
|
|||
- description: Send reminders to verify the accuracy of feature data.
|
||||
url: /cron/send_accuracy_notifications
|
||||
schedule: every monday 09:00
|
||||
- description: Notify any users that have been inactive for 6 months.
|
||||
url: /cron/warn_inactive_users
|
||||
schedule: 1st monday of month 9:00
|
||||
- description: Removes any users that have been inactive for 9 months.
|
||||
url: /cron/remove_inactive_users
|
||||
schedule: 1st monday of month 9:00
|
||||
- description: Copy over comment entities to new activity entities
|
||||
url: /cron/schema_migration_comment_activity
|
||||
schedule: 1 of jan 00:00
|
||||
|
|
|
@ -210,6 +210,10 @@ class APIHandler(BaseHandler):
|
|||
if not app_user:
|
||||
return False
|
||||
app_user.last_visit = datetime.now()
|
||||
# Reset the flag that states determines if the user has been notified
|
||||
# of inactivity if it has been set.
|
||||
if app_user.notified_inactive is not None:
|
||||
app_user.notified_inactive = False
|
||||
app_user.put()
|
||||
return True
|
||||
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
# Copyright 2022 Google Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License")
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from framework.basehandlers import FlaskHandler
|
||||
from internals.user_models import AppUser
|
||||
|
||||
class RemoveInactiveUsersHandler(FlaskHandler):
|
||||
DEFAULT_LAST_VISIT = datetime(2022, 8, 1) # 2022-08-01
|
||||
INACTIVE_REMOVE_DAYS = 270
|
||||
|
||||
def get_template_data(self, now=None):
|
||||
"""Removes any users that have been inactive for 9 months."""
|
||||
self.require_cron_header()
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
|
||||
q = AppUser.query()
|
||||
users = q.fetch()
|
||||
removed_users = []
|
||||
inactive_cutoff = now - timedelta(days=self.INACTIVE_REMOVE_DAYS)
|
||||
for user in users:
|
||||
# Site admins and editors are not removed for inactivity.
|
||||
if user.is_admin or user.is_site_editor:
|
||||
continue
|
||||
|
||||
# If the user does not have a last visit, it is assumed the last visit
|
||||
# is roughly the date the last_visit field was added.
|
||||
last_visit = user.last_visit or self.DEFAULT_LAST_VISIT
|
||||
if last_visit < inactive_cutoff:
|
||||
removed_users.append(user.email)
|
||||
logging.info(f'User removed: {user.email}')
|
||||
user.delete()
|
||||
|
||||
logging.info(f'{len(removed_users)} inactive users removed.')
|
||||
removed_users_output = ['Success', 'Removed users:']
|
||||
for user in removed_users:
|
||||
removed_users_output.append(user)
|
||||
return '\n'.join(removed_users_output)
|
|
@ -0,0 +1,75 @@
|
|||
# Copyright 2022 Google Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License")
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import testing_config # Must be imported before the module under test.
|
||||
from datetime import datetime
|
||||
|
||||
from internals import user_models
|
||||
from internals.inactive_users import RemoveInactiveUsersHandler
|
||||
|
||||
class RemoveInactiveUsersHandlerTest(testing_config.CustomTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.users = []
|
||||
active_user = user_models.AppUser(
|
||||
email="active_user@example.com", is_admin=False, is_site_editor=False,
|
||||
last_visit=datetime(2023, 8, 30))
|
||||
active_user.put()
|
||||
self.users.append(active_user)
|
||||
|
||||
inactive_user = user_models.AppUser(
|
||||
email="inactive_user@example.com", is_admin=False, is_site_editor=False,
|
||||
last_visit=datetime(2023, 2, 20))
|
||||
inactive_user.put()
|
||||
self.users.append(inactive_user)
|
||||
|
||||
really_inactive_user = user_models.AppUser(
|
||||
email="really_inactive_user@example.com", is_admin=False,
|
||||
is_site_editor=False, last_visit=datetime(2022, 10, 1))
|
||||
really_inactive_user.put()
|
||||
self.users.append(really_inactive_user)
|
||||
|
||||
active_admin = user_models.AppUser(
|
||||
email="active_admin@example.com", is_admin=True, is_site_editor=True,
|
||||
last_visit=datetime(2023, 9, 30))
|
||||
active_admin.put()
|
||||
self.users.append(active_admin)
|
||||
|
||||
inactive_admin = user_models.AppUser(
|
||||
email="inactive_admin@example.com", is_admin=True, is_site_editor=True,
|
||||
last_visit=datetime(2023, 3, 1))
|
||||
inactive_admin.put()
|
||||
self.users.append(inactive_admin)
|
||||
|
||||
active_site_editor = user_models.AppUser(
|
||||
email="active_site_editor@example.com", is_admin=False,
|
||||
is_site_editor=True, last_visit=datetime(2023, 7, 30))
|
||||
active_site_editor.put()
|
||||
self.users.append(active_site_editor)
|
||||
|
||||
inactive_site_editor = user_models.AppUser(
|
||||
email="inactive_site_editor@example.com", is_admin=False,
|
||||
is_site_editor=True, last_visit=datetime(2023, 2, 9))
|
||||
inactive_site_editor.put()
|
||||
self.users.append(inactive_site_editor)
|
||||
|
||||
def tearDown(self):
|
||||
for user in self.users:
|
||||
user.key.delete()
|
||||
|
||||
def test_remove_inactive_users(self):
|
||||
inactive_remover = RemoveInactiveUsersHandler()
|
||||
result = inactive_remover.get_template_data(now=datetime(2023, 9, 1))
|
||||
expected = 'Success\nRemoved users:\nreally_inactive_user@example.com'
|
||||
self.assertEqual(result, expected)
|
|
@ -256,6 +256,65 @@ class FeatureStar(ndb.Model):
|
|||
return user_prefs
|
||||
|
||||
|
||||
class NotifyInactiveUsersHandler(basehandlers.FlaskHandler):
|
||||
DEFAULT_LAST_VISIT = datetime(2022, 8, 1) # 2022-08-01
|
||||
INACTIVE_WARN_DAYS = 180
|
||||
EMAIL_TEMPLATE_PATH = 'inactive_user_email.html'
|
||||
|
||||
def get_template_data(self, now=None):
|
||||
"""Notify any users that have been inactive for 6 months."""
|
||||
self.require_cron_header()
|
||||
users_to_notify = self._determine_users_to_notify(now)
|
||||
email_tasks = self._build_email_tasks(users_to_notify)
|
||||
send_emails(email_tasks)
|
||||
|
||||
message = [f'{len(email_tasks)} users notified of inactivity.',
|
||||
'Notified users:']
|
||||
for task in email_tasks:
|
||||
message.append(task['to'])
|
||||
return {'message': '\n'.join(message)}
|
||||
|
||||
def _determine_users_to_notify(self, now=None):
|
||||
# date var can be passed in for testing purposes.
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
|
||||
q = user_models.AppUser.query()
|
||||
users = q.fetch()
|
||||
inactive_users = []
|
||||
inactive_cutoff = now - timedelta(days=self.INACTIVE_WARN_DAYS)
|
||||
|
||||
for user in users:
|
||||
# Site admins and editors aren't warned due to inactivity.
|
||||
# Also, users that have been previously notified are not notified again.
|
||||
if user.is_admin or user.is_site_editor or user.notified_inactive:
|
||||
continue
|
||||
|
||||
# If the user does not have a last visit, it is assumed the last visit
|
||||
# is roughly the date the last_visit field was added.
|
||||
last_visit = user.last_visit or self.DEFAULT_LAST_VISIT
|
||||
# Notify the user of inactivity if they haven't already been notified.
|
||||
if (last_visit < inactive_cutoff):
|
||||
inactive_users.append(user.email)
|
||||
user.notified_inactive = True
|
||||
user.put()
|
||||
return inactive_users
|
||||
|
||||
def _build_email_tasks(self, users_to_notify):
|
||||
email_tasks = []
|
||||
for email in users_to_notify:
|
||||
body_data = {'site_url': settings.SITE_URL}
|
||||
html = render_to_string(self.EMAIL_TEMPLATE_PATH, body_data)
|
||||
subject = f'Notice of WebStatus user inactivity for {email}'
|
||||
email_tasks.append({
|
||||
'to': email,
|
||||
'subject': subject,
|
||||
'reply_to': None,
|
||||
'html': html
|
||||
})
|
||||
return email_tasks
|
||||
|
||||
|
||||
class FeatureAccuracyHandler(basehandlers.FlaskHandler):
|
||||
JSONIFY = True
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
import collections
|
||||
import json
|
||||
import testing_config # Must be imported before the module under test.
|
||||
from datetime import datetime
|
||||
|
||||
import flask
|
||||
from unittest import mock
|
||||
|
@ -469,9 +470,14 @@ class FeatureStarTest(testing_config.CustomTestCase):
|
|||
notifier.FeatureStar.set_star(app_user_1.email, feature_1_id)
|
||||
notifier.FeatureStar.set_star(app_user_2.email, feature_1_id)
|
||||
|
||||
|
||||
user_1_email = app_user_1.email
|
||||
user_2_email = app_user_2.email
|
||||
app_user_1.key.delete()
|
||||
app_user_2.key.delete()
|
||||
actual = notifier.FeatureStar.get_feature_starrers(feature_1_id)
|
||||
self.assertCountEqual(
|
||||
[app_user_1.email, app_user_2.email],
|
||||
[user_1_email, user_2_email],
|
||||
[au.email for au in actual])
|
||||
|
||||
|
||||
|
@ -535,6 +541,67 @@ class FeatureAccuracyHandlerTest(testing_config.CustomTestCase):
|
|||
self.assertEqual(result, expected)
|
||||
|
||||
|
||||
class NotifyInactiveUsersHandlerTest(testing_config.CustomTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.users = []
|
||||
active_user = user_models.AppUser(
|
||||
email="active_user@example.com", is_admin=False, is_site_editor=False,
|
||||
last_visit=datetime(2023, 8, 30))
|
||||
active_user.put()
|
||||
self.users.append(active_user)
|
||||
|
||||
self.inactive_user = user_models.AppUser(
|
||||
email="inactive_user@example.com", is_admin=False, is_site_editor=False,
|
||||
last_visit=datetime(2023, 2, 20))
|
||||
self.inactive_user.put()
|
||||
self.users.append(self.inactive_user)
|
||||
|
||||
really_inactive_user = user_models.AppUser(
|
||||
email="really_inactive_user@example.com", is_admin=False,
|
||||
is_site_editor=False, last_visit=datetime(2022, 10, 1),
|
||||
notified_inactive=True)
|
||||
really_inactive_user.put()
|
||||
self.users.append(really_inactive_user)
|
||||
|
||||
active_admin = user_models.AppUser(
|
||||
email="active_admin@example.com", is_admin=True, is_site_editor=True,
|
||||
last_visit=datetime(2023, 9, 30))
|
||||
active_admin.put()
|
||||
self.users.append(active_admin)
|
||||
|
||||
inactive_admin = user_models.AppUser(
|
||||
email="inactive_admin@example.com", is_admin=True, is_site_editor=True,
|
||||
last_visit=datetime(2023, 3, 1))
|
||||
inactive_admin.put()
|
||||
self.users.append(inactive_admin)
|
||||
|
||||
active_site_editor = user_models.AppUser(
|
||||
email="active_site_editor@example.com", is_admin=False,
|
||||
is_site_editor=True, last_visit=datetime(2023, 7, 30))
|
||||
active_site_editor.put()
|
||||
self.users.append(active_site_editor)
|
||||
|
||||
inactive_site_editor = user_models.AppUser(
|
||||
email="inactive_site_editor@example.com", is_admin=False,
|
||||
is_site_editor=True, last_visit=datetime(2023, 2, 9))
|
||||
inactive_site_editor.put()
|
||||
self.users.append(inactive_site_editor)
|
||||
|
||||
def tearDown(self):
|
||||
for user in self.users:
|
||||
user.key.delete()
|
||||
|
||||
def test_determine_users_to_notify(self):
|
||||
inactive_notifier = notifier.NotifyInactiveUsersHandler()
|
||||
result = inactive_notifier.get_template_data(now=datetime(2023, 9, 1))
|
||||
expected = ('1 users notified of inactivity.\n'
|
||||
'Notified users:\ninactive_user@example.com')
|
||||
self.assertEqual(result.get('message', None), expected)
|
||||
# The inactive user who was notified should be flagged as notified.
|
||||
self.assertTrue(self.inactive_user.notified_inactive)
|
||||
|
||||
|
||||
class FunctionsTest(testing_config.CustomTestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
|
|
@ -98,6 +98,7 @@ class AppUser(ndb.Model):
|
|||
created = ndb.DateTimeProperty(auto_now_add=True)
|
||||
updated = ndb.DateTimeProperty(auto_now=True)
|
||||
last_visit = ndb.DateTimeProperty()
|
||||
notified_inactive = ndb.BooleanProperty()
|
||||
|
||||
def put(self, **kwargs):
|
||||
"""when we update an AppUser, also delete in rediscache."""
|
||||
|
|
3
main.py
3
main.py
|
@ -35,6 +35,7 @@ from internals import detect_intent
|
|||
from internals import fetchmetrics
|
||||
from internals import notifier
|
||||
from internals import data_backup
|
||||
from internals import inactive_users
|
||||
from internals import schema_migration
|
||||
from internals import deprecate_field
|
||||
from pages import blink_handler
|
||||
|
@ -189,6 +190,8 @@ internals_routes = [
|
|||
('/cron/update_blink_components', fetchmetrics.BlinkComponentHandler),
|
||||
('/cron/export_backup', data_backup.BackupExportHandler),
|
||||
('/cron/send_accuracy_notifications', notifier.FeatureAccuracyHandler),
|
||||
('/cron/warn_inactive_users', notifier.NotifyInactiveUsersHandler),
|
||||
('/cron/remove_inactive_users', inactive_users.RemoveInactiveUsersHandler),
|
||||
('/cron/schema_migration_comment_activity', schema_migration.MigrateCommentsToActivities),
|
||||
('/cron/write_standard_maturity', deprecate_field.WriteStandardMaturityHandler),
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<p>
|
||||
You are receiving this notification because you are registered as a user for
|
||||
ChromeStatus.com and have been inactive for longer than 6 months.
|
||||
</p>
|
||||
</br>
|
||||
<p>
|
||||
If you still need the ability to create features on ChromeStatus.com, signing
|
||||
into <a href="{{site_url}}">{{site_url}}</a> with this email will avoid any
|
||||
disruption in your access.
|
||||
</p>
|
||||
</br>
|
||||
<p>
|
||||
For the sake of site security, a user with 9 months of inactivity is
|
||||
granted normal user access. If you no longer need special access, no
|
||||
action is required on your part.
|
||||
Please note that you if you need special access again, you may contact us at
|
||||
webstatus-request@google.com to re-establish special access.
|
||||
</p>
|
Загрузка…
Ссылка в новой задаче