From eef53652fca419e129229325fb993ddfd22fef94 Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 21 Feb 2025 10:56:58 +0100 Subject: [PATCH 01/15] Add inactive users notification and deletion jobs --- udata/core/user/models.py | 23 ++++---- udata/core/user/tasks.py | 46 ++++++++++++++- udata/settings.py | 4 ++ udata/templates/mail/account_inactivity.html | 29 ++++++++++ udata/templates/mail/account_inactivity.txt | 22 +++++++ .../mail/inactive_account_deleted.html | 5 ++ .../mail/inactive_account_deleted.txt | 6 ++ udata/tests/user/test_user_tasks.py | 57 +++++++++++++++++++ 8 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 udata/templates/mail/account_inactivity.html create mode 100644 udata/templates/mail/account_inactivity.txt create mode 100644 udata/templates/mail/inactive_account_deleted.html create mode 100644 udata/templates/mail/inactive_account_deleted.txt create mode 100644 udata/tests/user/test_user_tasks.py diff --git a/udata/core/user/models.py b/udata/core/user/models.py index 1cab00ee58..4a028ebba8 100644 --- a/udata/core/user/models.py +++ b/udata/core/user/models.py @@ -237,7 +237,7 @@ def delete(self, *args, **kwargs): raise NotImplementedError("""This method should not be using directly. Use `mark_as_deleted` (or `_delete` if you know what you're doing)""") - def mark_as_deleted(self, notify: bool = True): + def mark_as_deleted(self, notify: bool = True, delete_comments: bool = True): if self.avatar.filename is not None: storage = storages.avatars storage.delete(self.avatar.filename) @@ -265,16 +265,17 @@ def mark_as_deleted(self, notify: bool = True): member for member in organization.members if member.user != self ] organization.save() - for discussion in Discussion.objects(discussion__posted_by=self): - # Remove all discussions with current user as only participant - if all(message.posted_by == self for message in discussion.discussion): - discussion.delete() - continue - - for message in discussion.discussion: - if message.posted_by == self: - message.content = "DELETED" - discussion.save() + if delete_comments: + for discussion in Discussion.objects(discussion__posted_by=self): + # Remove all discussions with current user as only participant + if all(message.posted_by == self for message in discussion.discussion): + discussion.delete() + continue + + for message in discussion.discussion: + if message.posted_by == self: + message.content = "DELETED" + discussion.save() Follow.objects(follower=self).delete() Follow.objects(following=self).delete() diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 7df5b36151..7fc321df51 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -1,10 +1,14 @@ import logging +from copy import copy +from datetime import datetime, timedelta + +from flask import current_app from udata import mail from udata.i18n import lazy_gettext as _ -from udata.tasks import task +from udata.tasks import job, task -from .models import datastore +from .models import User, datastore log = logging.getLogger(__name__) @@ -13,3 +17,41 @@ def send_test_mail(email): user = datastore.find_user(email=email) mail.send(_("Test mail"), user, "test") + + +# TODO: some questions to answer +# 1. how to make sure that we have the minimum time between notify and delete if for a reason +# or another, the notify job is not working. +# Should we store a notification_date? +# Should it run only on the 1st of each month? +# 2. How to deactivate/delete account? +# Should we set active=false instead of deleting account? +# Can we keep comments? +# What about other contents (Dataset, Org, etc.)? +# Our confidentiality policy states : "Données relatives au Contributeur qui s’inscrit" +# 3. Should we add a link to contact us if they can't connect? +# 4. Can we deal with ooooold inactive users with this system? Or should we do a specific Brevo campaign? +# 5. How to make the + / - timedelta more readable? + + +@job("notify-inactive-users") +def notify_inactive_users(self): + last_login_notification_date = ( + datetime.utcnow() + - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) + + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) + ) + + for user in User.objects(last_login_at__lte=last_login_notification_date): + mail.send(_("Account inactivity"), user, "account_inactivity", user=user) + + +@job("delete-inactive-users") +def delete_inactive_users(self): + last_login_deletion_date = datetime.utcnow() - timedelta( + days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365 + ) + for user in User.objects(last_login_at__lte=last_login_deletion_date): + copied_user = copy(user) + user.mark_as_deleted(notify=False, delete_comments=False) + mail.send(_("Inactive account deletion"), copied_user, "inactive_account_deleted") diff --git a/udata/settings.py b/udata/settings.py index 404f01453b..cc38cb3d74 100644 --- a/udata/settings.py +++ b/udata/settings.py @@ -115,6 +115,10 @@ class Defaults(object): SECURITY_RETURN_GENERIC_RESPONSES = False + # Inactive users settings + YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION = 3 + DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY = 30 + # Sentry configuration SENTRY_DSN = None SENTRY_TAGS = {} diff --git a/udata/templates/mail/account_inactivity.html b/udata/templates/mail/account_inactivity.html new file mode 100644 index 0000000000..d8d4be40e9 --- /dev/null +++ b/udata/templates/mail/account_inactivity.html @@ -0,0 +1,29 @@ +{% extends 'mail/base.html' %} +{% from 'mail/button.html' import mail_button %} + +{% block body %} +

+ {{ _( + 'Your account (%(user_email)s) has been inactive for %(inactivity_years)d years or more.', + user_email=user.email, + inactivity_years=config.YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION + ) + }} +

+
+

+ {{ _( + 'If you want to keep your account, please log in with your account on %(site)s.', + site=config.SITE_TITLE + ) + }} +

+
+

+ {{ _( + 'Without any action of your part, your account will be deleted within %(notify_delay)d days.', + notify_delay=config.DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY + ) + }} +

+{% endblock %} diff --git a/udata/templates/mail/account_inactivity.txt b/udata/templates/mail/account_inactivity.txt new file mode 100644 index 0000000000..e0dbd7c980 --- /dev/null +++ b/udata/templates/mail/account_inactivity.txt @@ -0,0 +1,22 @@ +{% extends 'mail/base.txt' %} + +{% block body %} +{{ _( + 'Your account (%(user_email)s) has been inactive for %(inactivity_years)d years or more.', + user_email=user.email, + inactivity_years=config.YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION + ) +}} + +{{ _( + 'If you want to keep your account, please log in with your account on %(site)s.', + site=config.SITE_TITLE + ) +}} + +{{ _( + 'Without any action of your part, your account will be deleted within %(notify_delay)d days.', + notify_delay=config.DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY + ) +}} +{% endblock %} diff --git a/udata/templates/mail/inactive_account_deleted.html b/udata/templates/mail/inactive_account_deleted.html new file mode 100644 index 0000000000..c15f18f7bc --- /dev/null +++ b/udata/templates/mail/inactive_account_deleted.html @@ -0,0 +1,5 @@ +{% extends 'mail/base.html' %} + +{% block body %} +

{{ _('Your account on %(site)s has been deleted due to inactivity', site=config.SITE_TITLE) }}

+{% endblock %} diff --git a/udata/templates/mail/inactive_account_deleted.txt b/udata/templates/mail/inactive_account_deleted.txt new file mode 100644 index 0000000000..4ac3cc3326 --- /dev/null +++ b/udata/templates/mail/inactive_account_deleted.txt @@ -0,0 +1,6 @@ +{% extends 'mail/base.txt' %} + +{% block body %} +{{ _('Your account on %(site)s has been deleted due to inactivity', site=config.SITE_TITLE) }}. + +{% endblock %} diff --git a/udata/tests/user/test_user_tasks.py b/udata/tests/user/test_user_tasks.py new file mode 100644 index 0000000000..70bea629eb --- /dev/null +++ b/udata/tests/user/test_user_tasks.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta + +from flask import current_app + +from udata.core.discussions.factories import DiscussionFactory +from udata.core.user import tasks +from udata.core.user.factories import UserFactory +from udata.i18n import gettext as _ +from udata.tests.api import APITestCase +from udata.tests.helpers import capture_mails + + +class UserTasksTest(APITestCase): + def test_notify_inactive_users(self): + last_login_notification_date = ( + datetime.utcnow() + - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) + + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) + - timedelta(days=1) # add margin + ) + + inactive_user = UserFactory(last_login_at=last_login_notification_date) + UserFactory(last_login_at=datetime.utcnow()) # Active user + + with capture_mails() as mails: + tasks.notify_inactive_users() + + # Assert (only one) mail has been sent + self.assertEqual(len(mails), 1) + self.assertEqual(mails[0].send_to, set([inactive_user.email])) + self.assertEqual(mails[0].subject, _("Account inactivity")) + + def test_delete_inactive_users(self): + last_login_deletion_date = ( + datetime.utcnow() + - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) + - timedelta(days=1) # add margin + ) + + inactive_user_to_delete = UserFactory(last_login_at=last_login_deletion_date) + UserFactory(last_login_at=datetime.utcnow()) # Active user + discussion = DiscussionFactory(user=inactive_user_to_delete) + discussion_title = discussion.title + + with capture_mails() as mails: + tasks.delete_inactive_users() + + # Assert (only one) mail has been sent + self.assertEqual(len(mails), 1) + self.assertEqual(mails[0].send_to, set([inactive_user_to_delete.email])) + self.assertEqual(mails[0].subject, _("Inactive account deletion")) + + # Assert user has been deleted but not its discussion + inactive_user_to_delete.reload() + discussion.reload() + self.assertEqual(inactive_user_to_delete.fullname, "DELETED DELETED") + self.assertEqual(discussion.title, discussion_title) From 6a93d7aee75c54a56a87a9a0504748729a828d41 Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 21 Feb 2025 11:07:59 +0100 Subject: [PATCH 02/15] Deactivate deletion by default in settings --- udata/core/user/tasks.py | 10 ++++++++++ udata/settings.py | 2 +- udata/tests/user/test_user_tasks.py | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 7fc321df51..2d1a73c554 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -36,6 +36,11 @@ def send_test_mail(email): @job("notify-inactive-users") def notify_inactive_users(self): + if not current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"]: + logging.warning( + "YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION setting is not set, no deletion planned" + ) + return last_login_notification_date = ( datetime.utcnow() - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) @@ -48,6 +53,11 @@ def notify_inactive_users(self): @job("delete-inactive-users") def delete_inactive_users(self): + if not current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"]: + logging.warning( + "YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION setting is not set, no deletion planned" + ) + return last_login_deletion_date = datetime.utcnow() - timedelta( days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365 ) diff --git a/udata/settings.py b/udata/settings.py index cc38cb3d74..0b7a7ebdd9 100644 --- a/udata/settings.py +++ b/udata/settings.py @@ -116,7 +116,7 @@ class Defaults(object): SECURITY_RETURN_GENERIC_RESPONSES = False # Inactive users settings - YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION = 3 + YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION = None DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY = 30 # Sentry configuration diff --git a/udata/tests/user/test_user_tasks.py b/udata/tests/user/test_user_tasks.py index 70bea629eb..1480cccb01 100644 --- a/udata/tests/user/test_user_tasks.py +++ b/udata/tests/user/test_user_tasks.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta +import pytest from flask import current_app from udata.core.discussions.factories import DiscussionFactory @@ -11,6 +12,7 @@ class UserTasksTest(APITestCase): + @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) def test_notify_inactive_users(self): last_login_notification_date = ( datetime.utcnow() @@ -30,6 +32,7 @@ def test_notify_inactive_users(self): self.assertEqual(mails[0].send_to, set([inactive_user.email])) self.assertEqual(mails[0].subject, _("Account inactivity")) + @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) def test_delete_inactive_users(self): last_login_deletion_date = ( datetime.utcnow() From 30d9032dfa1de36cc720c426ce4af5a04b6a779e Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 21 Feb 2025 11:08:06 +0100 Subject: [PATCH 03/15] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6235e7fdd..ef7643353c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Current (in progress) - The `extras` column in the resource catalog is now dumped as json [#3272](https://github.com/opendatateam/udata/pull/3272) and [#3273](https://github.com/opendatateam/udata/pull/3273) +- Add inactive users notification and deletion jobs [#3274](https://github.com/opendatateam/udata/pull/3274) ## 10.1.0 (2025-02-20) From 156b6da57ad9528f9bea51f8196e39eee6484a3b Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 21 Feb 2025 14:10:06 +0100 Subject: [PATCH 04/15] Add inactive_deletion_notified_at field in User model --- udata/core/user/models.py | 4 +++ udata/core/user/tasks.py | 32 +++++++++++++++----- udata/tests/user/test_user_tasks.py | 47 +++++++++++++++++++++++++---- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/udata/core/user/models.py b/udata/core/user/models.py index 4a028ebba8..63bb7bff3c 100644 --- a/udata/core/user/models.py +++ b/udata/core/user/models.py @@ -82,6 +82,10 @@ class User(WithMetrics, UserMixin, db.Document): ext = db.MapField(db.GenericEmbeddedDocumentField()) extras = db.ExtrasField() + # Used to track notification for automatic inactive users deletion + # when YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION is set + inactive_deletion_notified_at = db.DateTimeField() + before_save = Signal() after_save = Signal() on_create = Signal() diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 2d1a73c554..3fcad84d21 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -20,18 +20,18 @@ def send_test_mail(email): # TODO: some questions to answer -# 1. how to make sure that we have the minimum time between notify and delete if for a reason -# or another, the notify job is not working. -# Should we store a notification_date? -# Should it run only on the 1st of each month? +# 1. We went with storing a inactive_deletion_notified_at to make sure we don't delete +# accounts that haven't been notified long enough in advance. Are we bulletproof with this logic? # 2. How to deactivate/delete account? # Should we set active=false instead of deleting account? # Can we keep comments? # What about other contents (Dataset, Org, etc.)? # Our confidentiality policy states : "Données relatives au Contributeur qui s’inscrit" +# We may explicitely states the contents that will be deleted or not in mail # 3. Should we add a link to contact us if they can't connect? # 4. Can we deal with ooooold inactive users with this system? Or should we do a specific Brevo campaign? # 5. How to make the + / - timedelta more readable? +# 6. There is room for wording improvement here! @job("notify-inactive-users") @@ -41,14 +41,16 @@ def notify_inactive_users(self): "YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION setting is not set, no deletion planned" ) return - last_login_notification_date = ( + notification_comparison_date = ( datetime.utcnow() - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) ) - for user in User.objects(last_login_at__lte=last_login_notification_date): + for user in User.objects(current_login_at__lte=notification_comparison_date): mail.send(_("Account inactivity"), user, "account_inactivity", user=user) + user.inactive_deletion_notified_at = datetime.utcnow() + user.save() @job("delete-inactive-users") @@ -58,10 +60,24 @@ def delete_inactive_users(self): "YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION setting is not set, no deletion planned" ) return - last_login_deletion_date = datetime.utcnow() - timedelta( + + # Clear inactive_deletion_notified_at field if user has logged in since notification + for user in User.objects(inactive_deletion_notified_at__exists=True): + if user.current_login_at > user.inactive_deletion_notified_at: + user.inactive_deletion_notified_at = None + user.save() + + # Delete inactive users upon notification delay if user still hasn't logged in + deletion_comparison_date = datetime.utcnow() - timedelta( days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365 ) - for user in User.objects(last_login_at__lte=last_login_deletion_date): + notified_at = datetime.utcnow() - timedelta( + days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"] + ) + for user in User.objects( + current_login_at__lte=deletion_comparison_date, + inactive_deletion_notified_at__lte=notified_at, + ): copied_user = copy(user) user.mark_as_deleted(notify=False, delete_comments=False) mail.send(_("Inactive account deletion"), copied_user, "inactive_account_deleted") diff --git a/udata/tests/user/test_user_tasks.py b/udata/tests/user/test_user_tasks.py index 1480cccb01..103054a1fb 100644 --- a/udata/tests/user/test_user_tasks.py +++ b/udata/tests/user/test_user_tasks.py @@ -6,6 +6,7 @@ from udata.core.discussions.factories import DiscussionFactory from udata.core.user import tasks from udata.core.user.factories import UserFactory +from udata.core.user.models import User from udata.i18n import gettext as _ from udata.tests.api import APITestCase from udata.tests.helpers import capture_mails @@ -14,15 +15,15 @@ class UserTasksTest(APITestCase): @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) def test_notify_inactive_users(self): - last_login_notification_date = ( + notification_comparison_date = ( datetime.utcnow() - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) - timedelta(days=1) # add margin ) - inactive_user = UserFactory(last_login_at=last_login_notification_date) - UserFactory(last_login_at=datetime.utcnow()) # Active user + inactive_user = UserFactory(current_login_at=notification_comparison_date) + UserFactory(current_login_at=datetime.utcnow()) # Active user with capture_mails() as mails: tasks.notify_inactive_users() @@ -34,14 +35,23 @@ def test_notify_inactive_users(self): @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) def test_delete_inactive_users(self): - last_login_deletion_date = ( + deletion_comparison_date = ( datetime.utcnow() - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) - timedelta(days=1) # add margin ) - inactive_user_to_delete = UserFactory(last_login_at=last_login_deletion_date) - UserFactory(last_login_at=datetime.utcnow()) # Active user + notification_comparison_date = ( + datetime.utcnow() + - timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) + - timedelta(days=1) # add margin + ) + + inactive_user_to_delete = UserFactory( + current_login_at=deletion_comparison_date, + inactive_deletion_notified_at=notification_comparison_date, + ) + UserFactory(current_login_at=datetime.utcnow()) # Active user discussion = DiscussionFactory(user=inactive_user_to_delete) discussion_title = discussion.title @@ -58,3 +68,28 @@ def test_delete_inactive_users(self): discussion.reload() self.assertEqual(inactive_user_to_delete.fullname, "DELETED DELETED") self.assertEqual(discussion.title, discussion_title) + + @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) + def test_keep_inactive_users_that_logged_in(self): + notification_comparison_date = ( + datetime.utcnow() + - timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) + - timedelta(days=1) # add margin + ) + + inactive_user_that_logged_in_since_notification = UserFactory( + current_login_at=datetime.utcnow(), + inactive_deletion_notified_at=notification_comparison_date, + ) + + with capture_mails() as mails: + tasks.delete_inactive_users() + + # Assert no mail has been sent + self.assertEqual(len(mails), 0) + + # Assert user hasn't been deleted and won't be deleted + self.assertEqual(User.objects().count(), 1) + user = User.objects().first() + self.assertEqual(user, inactive_user_that_logged_in_since_notification) + self.assertIsNone(user.inactive_deletion_notified_at) From bfbab809d812f8c98f56ea308e6ddb36202107f1 Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 21 Feb 2025 14:53:04 +0100 Subject: [PATCH 05/15] Update mail wording --- udata/core/user/tasks.py | 15 +++++++++++++-- udata/tests/user/test_user_tasks.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 3fcad84d21..4afe0bea43 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -48,7 +48,12 @@ def notify_inactive_users(self): ) for user in User.objects(current_login_at__lte=notification_comparison_date): - mail.send(_("Account inactivity"), user, "account_inactivity", user=user) + mail.send( + _("Inactivity of your {site} account").format(site=current_app.config["SITE_TITLE"]), + user, + "account_inactivity", + user=user, + ) user.inactive_deletion_notified_at = datetime.utcnow() user.save() @@ -80,4 +85,10 @@ def delete_inactive_users(self): ): copied_user = copy(user) user.mark_as_deleted(notify=False, delete_comments=False) - mail.send(_("Inactive account deletion"), copied_user, "inactive_account_deleted") + mail.send( + _("Deletion of your inactive {site} account").format( + site=current_app.config["SITE_TITLE"] + ), + copied_user, + "inactive_account_deleted", + ) diff --git a/udata/tests/user/test_user_tasks.py b/udata/tests/user/test_user_tasks.py index 103054a1fb..628ea6a33d 100644 --- a/udata/tests/user/test_user_tasks.py +++ b/udata/tests/user/test_user_tasks.py @@ -31,7 +31,10 @@ def test_notify_inactive_users(self): # Assert (only one) mail has been sent self.assertEqual(len(mails), 1) self.assertEqual(mails[0].send_to, set([inactive_user.email])) - self.assertEqual(mails[0].subject, _("Account inactivity")) + self.assertEqual( + mails[0].subject, + _("Inactivity of your {site} account").format(site=current_app.config["SITE_TITLE"]), + ) @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) def test_delete_inactive_users(self): @@ -61,7 +64,14 @@ def test_delete_inactive_users(self): # Assert (only one) mail has been sent self.assertEqual(len(mails), 1) self.assertEqual(mails[0].send_to, set([inactive_user_to_delete.email])) - self.assertEqual(mails[0].subject, _("Inactive account deletion")) + self.assertEqual( + mails[0].subject, + _( + _("Deletion of your inactive {site} account").format( + site=current_app.config["SITE_TITLE"] + ) + ), + ) # Assert user has been deleted but not its discussion inactive_user_to_delete.reload() From c504c83b15385422ea30015d1315fa0e2f22c81c Mon Sep 17 00:00:00 2001 From: maudetes Date: Thu, 27 Feb 2025 15:35:13 +0100 Subject: [PATCH 06/15] Rewording account inactivity email --- udata/templates/mail/account_inactivity.html | 2 +- udata/templates/mail/account_inactivity.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/udata/templates/mail/account_inactivity.html b/udata/templates/mail/account_inactivity.html index d8d4be40e9..8e604aa9fb 100644 --- a/udata/templates/mail/account_inactivity.html +++ b/udata/templates/mail/account_inactivity.html @@ -21,7 +21,7 @@

{{ _( - 'Without any action of your part, your account will be deleted within %(notify_delay)d days.', + 'Without logging in, your account will be deleted within %(notify_delay)d days.', notify_delay=config.DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY ) }} diff --git a/udata/templates/mail/account_inactivity.txt b/udata/templates/mail/account_inactivity.txt index e0dbd7c980..b1bae32d09 100644 --- a/udata/templates/mail/account_inactivity.txt +++ b/udata/templates/mail/account_inactivity.txt @@ -15,7 +15,7 @@ }} {{ _( - 'Without any action of your part, your account will be deleted within %(notify_delay)d days.', + 'Without logging in, your account will be deleted within %(notify_delay)d days.', notify_delay=config.DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY ) }} From e8f92cd14eef373965de6d5d189eb611f9c60b9b Mon Sep 17 00:00:00 2001 From: maudetes Date: Thu, 27 Feb 2025 15:54:25 +0100 Subject: [PATCH 07/15] Keep discussions by default when deleting a user Add a dedicated delete_comments arg in User DELETE API --- udata/core/user/api.py | 9 ++++++++- udata/core/user/models.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/udata/core/user/api.py b/udata/core/user/api.py index ff44edefd4..d69fe723cb 100644 --- a/udata/core/user/api.py +++ b/udata/core/user/api.py @@ -275,6 +275,13 @@ def post(self, user): location="args", default=False, ) +delete_parser.add_argument( + "delete_comments", + type=bool, + help="Delete comments posted by the user upon user deletion", + location="args", + default=False, +) @ns.route("//", endpoint="user") @@ -317,7 +324,7 @@ def delete(self, user): 403, "You cannot delete yourself with this API. " + 'Use the "me" API instead.' ) - user.mark_as_deleted(notify=not args["no_mail"]) + user.mark_as_deleted(notify=not args["no_mail"], delete_comments=args["delete_comments"]) return "", 204 diff --git a/udata/core/user/models.py b/udata/core/user/models.py index 63bb7bff3c..7edbd40795 100644 --- a/udata/core/user/models.py +++ b/udata/core/user/models.py @@ -241,7 +241,7 @@ def delete(self, *args, **kwargs): raise NotImplementedError("""This method should not be using directly. Use `mark_as_deleted` (or `_delete` if you know what you're doing)""") - def mark_as_deleted(self, notify: bool = True, delete_comments: bool = True): + def mark_as_deleted(self, notify: bool = True, delete_comments: bool = False): if self.avatar.filename is not None: storage = storages.avatars storage.delete(self.avatar.filename) From 18eab4815d7013e6f01900afe8d8843ee47d9dce Mon Sep 17 00:00:00 2001 From: maudetes Date: Thu, 27 Feb 2025 16:12:50 +0100 Subject: [PATCH 08/15] Add and update comments deletion tests on user mark as deleted --- udata/core/user/tests/test_user_model.py | 41 ++++++++++++------ udata/tests/api/test_user_api.py | 55 ++++++++++++++++++++---- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/udata/core/user/tests/test_user_model.py b/udata/core/user/tests/test_user_model.py index f579a720fc..cfbcb1634a 100644 --- a/udata/core/user/tests/test_user_model.py +++ b/udata/core/user/tests/test_user_model.py @@ -18,7 +18,7 @@ def test_mark_as_deleted(self): user = UserFactory() other_user = UserFactory() org = OrganizationFactory(editors=[user]) - discussion_only_user = DiscussionFactory( + discussion = DiscussionFactory( user=user, subject=org, discussion=[ @@ -26,14 +26,6 @@ def test_mark_as_deleted(self): MessageDiscussionFactory(posted_by=user), ], ) - discussion_with_other = DiscussionFactory( - user=other_user, - subject=org, - discussion=[ - MessageDiscussionFactory(posted_by=other_user), - MessageDiscussionFactory(posted_by=user), - ], - ) user_follow_org = Follow.objects.create(follower=user, following=org) user_followed = Follow.objects.create(follower=other_user, following=user) @@ -42,15 +34,40 @@ def test_mark_as_deleted(self): org.reload() assert len(org.members) == 0 - assert Discussion.objects(id=discussion_only_user.id).first() is None - discussion_with_other.reload() - assert discussion_with_other.discussion[1].content == "DELETED" + # discussions are kept by default + discussion.reload() + assert len(discussion.discussion) == 2 + assert discussion.discussion[1].content != "DELETED" assert Follow.objects(id=user_follow_org.id).first() is None assert Follow.objects(id=user_followed.id).first() is None assert user.slug == "deleted" + def test_mark_as_deleted_with_comments_deletion(self): + user = UserFactory() + other_user = UserFactory() + discussion_only_user = DiscussionFactory( + user=user, + discussion=[ + MessageDiscussionFactory(posted_by=user), + MessageDiscussionFactory(posted_by=user), + ], + ) + discussion_with_other = DiscussionFactory( + user=other_user, + discussion=[ + MessageDiscussionFactory(posted_by=other_user), + MessageDiscussionFactory(posted_by=user), + ], + ) + + user.mark_as_deleted(delete_comments=True) + + assert Discussion.objects(id=discussion_only_user.id).first() is None + discussion_with_other.reload() + assert discussion_with_other.discussion[1].content == "DELETED" + def test_mark_as_deleted_slug_multiple(self): user = UserFactory() other_user = UserFactory() diff --git a/udata/tests/api/test_user_api.py b/udata/tests/api/test_user_api.py index 6cc2f961f5..3d76f384b1 100644 --- a/udata/tests/api/test_user_api.py +++ b/udata/tests/api/test_user_api.py @@ -1,8 +1,9 @@ from flask import url_for from udata.core import storages +from udata.core.discussions.factories import DiscussionFactory, MessageDiscussionFactory from udata.core.user.factories import AdminFactory, UserFactory -from udata.models import Follow +from udata.models import Discussion, Follow from udata.tests.helpers import capture_mails, create_test_image from udata.utils import faker @@ -352,36 +353,74 @@ def test_user_roles(self): def test_delete_user(self): user = AdminFactory() self.login(user) - other_user = UserFactory() + user_to_delete = UserFactory() file = create_test_image() + discussion = DiscussionFactory( + user=user_to_delete, + discussion=[ + MessageDiscussionFactory(posted_by=user_to_delete), + MessageDiscussionFactory(posted_by=user_to_delete), + ], + ) response = self.post( - url_for("api.user_avatar", user=other_user), + url_for("api.user_avatar", user=user_to_delete), {"file": (file, "test.png")}, json=False, ) with capture_mails() as mails: - response = self.delete(url_for("api.user", user=other_user)) + response = self.delete(url_for("api.user", user=user_to_delete)) self.assertEqual(list(storages.avatars.list_files()), []) self.assert204(response) self.assertEquals(len(mails), 1) - other_user.reload() - response = self.delete(url_for("api.user", user=other_user)) + user_to_delete.reload() + response = self.delete(url_for("api.user", user=user_to_delete)) self.assert410(response) response = self.delete(url_for("api.user", user=user)) self.assert403(response) + # discussions are kept by default + discussion.reload() + assert len(discussion.discussion) == 2 + assert discussion.discussion[1].content != "DELETED" + def test_delete_user_without_notify(self): user = AdminFactory() self.login(user) - other_user = UserFactory() + user_to_delete = UserFactory() with capture_mails() as mails: - response = self.delete(url_for("api.user", user=other_user, no_mail=True)) + response = self.delete(url_for("api.user", user=user_to_delete, no_mail=True)) self.assert204(response) self.assertEqual(len(mails), 0) + def test_delete_user_with_comments_deletion(self): + user = AdminFactory() + self.login(user) + user_to_delete = UserFactory() + discussion_only_user = DiscussionFactory( + user=user_to_delete, + discussion=[ + MessageDiscussionFactory(posted_by=user_to_delete), + MessageDiscussionFactory(posted_by=user_to_delete), + ], + ) + discussion_with_other = DiscussionFactory( + user=user, + discussion=[ + MessageDiscussionFactory(posted_by=user), + MessageDiscussionFactory(posted_by=user_to_delete), + ], + ) + + response = self.delete(url_for("api.user", user=user_to_delete, delete_comments=True)) + self.assert204(response) + + assert Discussion.objects(id=discussion_only_user.id).first() is None + discussion_with_other.reload() + assert discussion_with_other.discussion[1].content == "DELETED" + def test_contact_points(self): user = AdminFactory() self.login(user) From eb57f2a24cc52612effb7937e22372d7146f46d2 Mon Sep 17 00:00:00 2001 From: maudetes Date: Thu, 27 Feb 2025 16:21:12 +0100 Subject: [PATCH 09/15] Update comments deletion tests on DELETE /me --- udata/tests/api/test_me_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/udata/tests/api/test_me_api.py b/udata/tests/api/test_me_api.py index 18973c2219..c4b17cdeef 100644 --- a/udata/tests/api/test_me_api.py +++ b/udata/tests/api/test_me_api.py @@ -332,7 +332,7 @@ def test_delete(self): # The discussions are kept but the messages are anonymized self.assertEqual(len(discussion.discussion), 2) - self.assertEqual(discussion.discussion[0].content, "DELETED") + self.assertEqual(discussion.discussion[0].posted_by.fullname, "DELETED DELETED") self.assertEqual(discussion.discussion[1].content, other_disc_msg_content) # The datasets are unchanged From 030670288433259a33ae8b86ac677cc6b4267110 Mon Sep 17 00:00:00 2001 From: maudetes Date: Thu, 27 Feb 2025 17:25:10 +0100 Subject: [PATCH 10/15] Add MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS setting to prevent notifying the entire stock of inactive users at first go --- udata/core/user/tasks.py | 13 ++++++++-- udata/settings.py | 1 + udata/tests/user/test_user_tasks.py | 39 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 4afe0bea43..9b27ccebc4 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -47,7 +47,13 @@ def notify_inactive_users(self): + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) ) - for user in User.objects(current_login_at__lte=notification_comparison_date): + for i, user in enumerate( + User.objects( + deleted=None, + inactive_deletion_notified_at=None, + current_login_at__lte=notification_comparison_date, + ) + ): mail.send( _("Inactivity of your {site} account").format(site=current_app.config["SITE_TITLE"]), user, @@ -56,6 +62,9 @@ def notify_inactive_users(self): ) user.inactive_deletion_notified_at = datetime.utcnow() user.save() + if i > current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"]: + logging.warning("MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS reached, stopping here.") + return @job("delete-inactive-users") @@ -67,7 +76,7 @@ def delete_inactive_users(self): return # Clear inactive_deletion_notified_at field if user has logged in since notification - for user in User.objects(inactive_deletion_notified_at__exists=True): + for user in User.objects(deleted=None, inactive_deletion_notified_at__exists=True): if user.current_login_at > user.inactive_deletion_notified_at: user.inactive_deletion_notified_at = None user.save() diff --git a/udata/settings.py b/udata/settings.py index 0b7a7ebdd9..6aac8b5f3b 100644 --- a/udata/settings.py +++ b/udata/settings.py @@ -118,6 +118,7 @@ class Defaults(object): # Inactive users settings YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION = None DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY = 30 + MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS = 200 # Sentry configuration SENTRY_DSN = None diff --git a/udata/tests/user/test_user_tasks.py b/udata/tests/user/test_user_tasks.py index 628ea6a33d..5eba901789 100644 --- a/udata/tests/user/test_user_tasks.py +++ b/udata/tests/user/test_user_tasks.py @@ -36,6 +36,45 @@ def test_notify_inactive_users(self): _("Inactivity of your {site} account").format(site=current_app.config["SITE_TITLE"]), ) + # We shouldn't notify users twice + with capture_mails() as mails: + tasks.notify_inactive_users() + + self.assertEqual(len(mails), 0) + + @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) + @pytest.mark.options(MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS=10) + def test_notify_inactive_users_max_notifications(self): + notification_comparison_date = ( + datetime.utcnow() + - timedelta(days=current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"] * 365) + + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) + - timedelta(days=1) # add margin + ) + + NB_USERS_TO_NOTIFY = 15 + + [UserFactory(current_login_at=notification_comparison_date) for _ in range(15)] + UserFactory(current_login_at=datetime.utcnow()) # Active user + + with capture_mails() as mails: + tasks.notify_inactive_users() + + # Assert MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS mails have been sent + self.assertEqual( + len(mails), current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"] + ) + + # Second batch + with capture_mails() as mails: + tasks.notify_inactive_users() + + # Assert what's left have been sent + self.assertEqual( + len(mails), + NB_USERS_TO_NOTIFY - current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"], + ) + @pytest.mark.options(YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION=3) def test_delete_inactive_users(self): deletion_comparison_date = ( From 2835cbccef17cda12485f8b09f8ae6b9405d7e31 Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 28 Feb 2025 09:40:20 +0100 Subject: [PATCH 11/15] Actually sends expected number of notifications --- udata/core/user/tasks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 9b27ccebc4..36e2bd2ae3 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -54,6 +54,9 @@ def notify_inactive_users(self): current_login_at__lte=notification_comparison_date, ) ): + if i >= current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"]: + logging.warning("MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS reached, stopping here.") + return mail.send( _("Inactivity of your {site} account").format(site=current_app.config["SITE_TITLE"]), user, @@ -62,9 +65,6 @@ def notify_inactive_users(self): ) user.inactive_deletion_notified_at = datetime.utcnow() user.save() - if i > current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"]: - logging.warning("MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS reached, stopping here.") - return @job("delete-inactive-users") From abbc2f44344ec28a3c62ff73a4be3dba1c5d1200 Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 28 Feb 2025 09:43:23 +0100 Subject: [PATCH 12/15] Remove WIP comments --- udata/core/user/tasks.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 36e2bd2ae3..8bcf95d6f6 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -19,21 +19,6 @@ def send_test_mail(email): mail.send(_("Test mail"), user, "test") -# TODO: some questions to answer -# 1. We went with storing a inactive_deletion_notified_at to make sure we don't delete -# accounts that haven't been notified long enough in advance. Are we bulletproof with this logic? -# 2. How to deactivate/delete account? -# Should we set active=false instead of deleting account? -# Can we keep comments? -# What about other contents (Dataset, Org, etc.)? -# Our confidentiality policy states : "Données relatives au Contributeur qui s’inscrit" -# We may explicitely states the contents that will be deleted or not in mail -# 3. Should we add a link to contact us if they can't connect? -# 4. Can we deal with ooooold inactive users with this system? Or should we do a specific Brevo campaign? -# 5. How to make the + / - timedelta more readable? -# 6. There is room for wording improvement here! - - @job("notify-inactive-users") def notify_inactive_users(self): if not current_app.config["YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION"]: @@ -89,6 +74,7 @@ def delete_inactive_users(self): days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"] ) for user in User.objects( + deleted=None, current_login_at__lte=deletion_comparison_date, inactive_deletion_notified_at__lte=notified_at, ): From 848f6704ab896ca4d989eec17f1921c4b31fb0e3 Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 28 Feb 2025 10:19:41 +0100 Subject: [PATCH 13/15] Addind some logs --- udata/core/user/tasks.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 8bcf95d6f6..1353aa88a9 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -48,9 +48,12 @@ def notify_inactive_users(self): "account_inactivity", user=user, ) + logging.debug(f"Notified {user.email} of account inactivity") user.inactive_deletion_notified_at = datetime.utcnow() user.save() + logging.info(f"Notified {i+1} inactive users") + @job("delete-inactive-users") def delete_inactive_users(self): @@ -73,13 +76,15 @@ def delete_inactive_users(self): notified_at = datetime.utcnow() - timedelta( days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"] ) - for user in User.objects( + users_to_delete = User.objects( deleted=None, current_login_at__lte=deletion_comparison_date, inactive_deletion_notified_at__lte=notified_at, - ): + ) + for user in users_to_delete: copied_user = copy(user) user.mark_as_deleted(notify=False, delete_comments=False) + logging.warning(f"Deleted user {copied_user.email} due to account inactivity") mail.send( _("Deletion of your inactive {site} account").format( site=current_app.config["SITE_TITLE"] @@ -87,3 +92,4 @@ def delete_inactive_users(self): copied_user, "inactive_account_deleted", ) + logging.info(f"Deleted {users_to_delete.count()} inactive users") From c31ecc112eb281b93b6c3ed298312f7f8f8c6f1e Mon Sep 17 00:00:00 2001 From: maudetes Date: Fri, 28 Feb 2025 10:56:43 +0100 Subject: [PATCH 14/15] Fix logging --- udata/core/user/tasks.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/udata/core/user/tasks.py b/udata/core/user/tasks.py index 1353aa88a9..425b0acb04 100644 --- a/udata/core/user/tasks.py +++ b/udata/core/user/tasks.py @@ -32,13 +32,12 @@ def notify_inactive_users(self): + timedelta(days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]) ) - for i, user in enumerate( - User.objects( - deleted=None, - inactive_deletion_notified_at=None, - current_login_at__lte=notification_comparison_date, - ) - ): + users_to_notify = User.objects( + deleted=None, + inactive_deletion_notified_at=None, + current_login_at__lte=notification_comparison_date, + ) + for i, user in enumerate(users_to_notify): if i >= current_app.config["MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS"]: logging.warning("MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS reached, stopping here.") return @@ -52,7 +51,7 @@ def notify_inactive_users(self): user.inactive_deletion_notified_at = datetime.utcnow() user.save() - logging.info(f"Notified {i+1} inactive users") + logging.info(f"Notified {users_to_notify.count()} inactive users") @job("delete-inactive-users") From f66784caa4fcf507b147b1432c47a81b9e501ae9 Mon Sep 17 00:00:00 2001 From: maudetes Date: Mon, 3 Mar 2025 17:15:49 +0100 Subject: [PATCH 15/15] Update changelog and documentation --- CHANGELOG.md | 2 ++ docs/adapting-settings.md | 24 ++++++++++++++++++++++++ udata/settings.py | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7643353c..ecf409b83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - The `extras` column in the resource catalog is now dumped as json [#3272](https://github.com/opendatateam/udata/pull/3272) and [#3273](https://github.com/opendatateam/udata/pull/3273) - Add inactive users notification and deletion jobs [#3274](https://github.com/opendatateam/udata/pull/3274) + - these jobs can be scheduled daily for example + - `YEARS_OF_INACTIVITY_BEFORE_DELETION` setting must be configured at least to activate it ## 10.1.0 (2025-02-20) diff --git a/docs/adapting-settings.md b/docs/adapting-settings.md index 0cbdd37e49..67c17447bf 100644 --- a/docs/adapting-settings.md +++ b/docs/adapting-settings.md @@ -443,6 +443,30 @@ If set to `True`, the new passwords will need to contain at least one uppercase If set to `True`, the new passwords will need to contain at least one symbol. +## Inactive users deletion options + +### YEARS_OF_INACTIVITY_BEFORE_DELETION + +**default**: `None` + +Set this setting to an int value to activate inactive users account notification and deletion. +It will filter on users with an account inactive for longer than this number of years. + +#### DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY + +**default**: 30 + +The delay of days between inactive user notification and its account deletion. + +### MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS + +**default**: 200 + +The maximal number of notifications to send per `notify-inactive-users` job. +If activating `YEARS_OF_INACTIVITY_BEFORE_DELETION`, you can slowly increase this configuration +batch after batch. The limitation is made to prevent sending too many mail notifications at once +resulting in your mail domain being flagged as spam. + ## Flask-Cache options udata uses Flask-Cache to handle cache and use Redis by default. diff --git a/udata/settings.py b/udata/settings.py index 6aac8b5f3b..cb2d9ddb6f 100644 --- a/udata/settings.py +++ b/udata/settings.py @@ -116,7 +116,7 @@ class Defaults(object): SECURITY_RETURN_GENERIC_RESPONSES = False # Inactive users settings - YEARS_OF_INACTIVITY_BEFORE_DEACTIVATION = None + YEARS_OF_INACTIVITY_BEFORE_DELETION = None DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY = 30 MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS = 200