Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add inactive users notification and deletion jobs #3274

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

- Allow temporal coverage with only a start date [#3192](https://github.com/opendatateam/udata/pull/3192)
- 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
- Ensure we populate slug properly on user deletion [#3277](https://github.com/opendatateam/udata/pull/3277)

## 10.1.0 (2025-02-20)
Expand Down
24 changes: 24 additions & 0 deletions docs/adapting-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion udata/core/user/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("/<user:user>/", endpoint="user")
Expand Down Expand Up @@ -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


Expand Down
27 changes: 16 additions & 11 deletions udata/core/user/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -237,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):
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)
Expand Down Expand Up @@ -265,16 +269,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()

Expand Down
83 changes: 81 additions & 2 deletions udata/core/user/tasks.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -13,3 +17,78 @@
def send_test_mail(email):
user = datastore.find_user(email=email)
mail.send(_("Test mail"), user, "test")


@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
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"])
)

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
mail.send(
_("Inactivity of your {site} account").format(site=current_app.config["SITE_TITLE"]),
user,
"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 {users_to_notify.count()} inactive users")


@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

# Clear inactive_deletion_notified_at field if user has logged in since notification
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()

# 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
)
notified_at = datetime.utcnow() - timedelta(
days=current_app.config["DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY"]
)
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"]
),
copied_user,
"inactive_account_deleted",
)
logging.info(f"Deleted {users_to_delete.count()} inactive users")
41 changes: 29 additions & 12 deletions udata/core/user/tests/test_user_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,14 @@ 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=[
MessageDiscussionFactory(posted_by=user),
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)

Expand All @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions udata/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ class Defaults(object):

SECURITY_RETURN_GENERIC_RESPONSES = False

# Inactive users settings
YEARS_OF_INACTIVITY_BEFORE_DELETION = None
DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY = 30
MAX_NUMBER_OF_USER_INACTIVITY_NOTIFICATIONS = 200

# Sentry configuration
SENTRY_DSN = None
SENTRY_TAGS = {}
Expand Down
29 changes: 29 additions & 0 deletions udata/templates/mail/account_inactivity.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends 'mail/base.html' %}
{% from 'mail/button.html' import mail_button %}

{% block body %}
<p style="margin: 0;padding: 0;">
{{ _(
'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
)
}}
</p>
<br/>
<p style="margin: 0;padding: 0;"><b>
{{ _(
'If you want to keep your account, please log in with your account on %(site)s.',
site=config.SITE_TITLE
)
}}
</b></p>
<br/>
<p style="margin: 0;padding: 0;">
{{ _(
'Without logging in, your account will be deleted within %(notify_delay)d days.',
notify_delay=config.DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY
)
}}
</p>
{% endblock %}
22 changes: 22 additions & 0 deletions udata/templates/mail/account_inactivity.txt
Original file line number Diff line number Diff line change
@@ -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 logging in, your account will be deleted within %(notify_delay)d days.',
notify_delay=config.DAYS_BEFORE_ACCOUNT_INACTIVITY_NOTIFY_DELAY
)
}}
{% endblock %}
5 changes: 5 additions & 0 deletions udata/templates/mail/inactive_account_deleted.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% extends 'mail/base.html' %}

{% block body %}
<p style="margin: 0;padding: 0;">{{ _('Your account on %(site)s has been deleted due to inactivity', site=config.SITE_TITLE) }}</p>
{% endblock %}
6 changes: 6 additions & 0 deletions udata/templates/mail/inactive_account_deleted.txt
Original file line number Diff line number Diff line change
@@ -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 %}
2 changes: 1 addition & 1 deletion udata/tests/api/test_me_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading