* 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:
Daniel Smith 2022-09-15 13:43:22 -07:00 коммит произвёл GitHub
Родитель 43cf1ef933
Коммит a49ef32b30
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 291 добавлений и 3 удалений

Просмотреть файл

@ -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."""

6
cron.yaml Executable file → Normal file
Просмотреть файл

@ -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."""

Просмотреть файл

@ -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>