Merge pull request !2847 from rhodecode-enterprise-ce feature/RCCE-322_Notify-inactive-users

feature: implements find\notify\delete inactive users
This commit is contained in:
Andrii Verbytskyi 2025-10-31 14:36:32 +00:00
commit 1c563b0157
7 changed files with 419 additions and 33 deletions

View file

@ -107,7 +107,43 @@ def send_email(recipients, subject, body="", html_body="", email_config=None, ex
recipients += admins
# translate our LEGACY config into the one that pyramid_mailer supports
transformed_email_confing = dict(
transformed_email_confing = transform_legacy_email_config(email_config, mail_server)
if extra_headers is None:
extra_headers = {}
extra_headers.setdefault("Date", formatdate(time.time()))
if "thread_ids" in extra_headers:
thread_ids = extra_headers.pop("thread_ids")
extra_headers["References"] = " ".join(f"<{t}>" for t in thread_ids)
try:
mailer = get_mailer(transformed_email_confing, email_config)
message = Message(
subject=subject,
sender=transformed_email_confing["default_sender"],
recipients=recipients,
body=body,
html=html_body,
extra_headers=extra_headers,
)
mailer.send_immediately(message)
statsd = StatsdClient.statsd
if statsd:
statsd.incr("rhodecode_email_sent_total")
except Exception:
log.exception("Mail sending failed")
return False
return True
def transform_legacy_email_config(email_config: dict[Any, Any] | Any, mail_server: Any | None) -> dict[
str, None | int | bool | str | Any]:
return dict(
host=mail_server,
port=email_config.get("smtp_port", 25),
username=email_config.get("smtp_username", None),
@ -128,42 +164,14 @@ def send_email(recipients, subject, body="", html_body="", email_config=None, ex
# sendmail_template='',
)
if extra_headers is None:
extra_headers = {}
extra_headers.setdefault("Date", formatdate(time.time()))
if "thread_ids" in extra_headers:
thread_ids = extra_headers.pop("thread_ids")
extra_headers["References"] = " ".join("<{}>".format(t) for t in thread_ids)
try:
mailer = _get_mailer(transformed_email_confing, email_config)
message = Message(
subject=subject,
sender=transformed_email_confing["default_sender"],
recipients=recipients,
body=body,
html=html_body,
extra_headers=extra_headers,
)
mailer.send_immediately(message)
statsd = StatsdClient.statsd
if statsd:
statsd.incr("rhodecode_email_sent_total")
except Exception:
log.exception("Mail sending failed")
return False
return True
def _get_mailer(transformed_email_conf: dict[str, Any], original_email_conf: dict[str, Any]) -> Mailer | DebugMailer:
def get_mailer(transformed_email_conf: dict[str, Any], original_email_conf: dict[str, Any]) -> Mailer | DebugMailer:
log = get_logger(get_mailer)
dev_mode = str2bool(original_email_conf.get("development_email"))
if dev_mode:
emails_path = original_email_conf.get("local_email_store") or "./local_emails"
log.debug(f"Getting development email. emails folder path: {emails_path}")
return DebugMailer(emails_path)
return Mailer(**transformed_email_conf)

View file

@ -0,0 +1,236 @@
import csv
import decimal
import logging
import os
import time
from datetime import datetime, timezone, date
from email.utils import formatdate
from typing import List
from sqlalchemy.inspection import inspect as sa_inspect
from pyramid_mailer.message import Message
import rhodecode
from rhodecode.lib.celerylib.tasks import transform_legacy_email_config, get_mailer
from rhodecode.lib.statsd_client import StatsdClient
from rhodecode.lib.type_utils import str2bool
from rhodecode.model.db import User
from rhodecode.model.meta import Base
from rhodecode.model.user import UserModel
log = logging.getLogger(__name__)
SENSITIVE_FIELDS = {"password", "api_key", "user_auth_tokens", "user_ssh_keys"}
EMAIL_SUBJECT = "Action needed: inactive account scheduled for deletion"
EMAIL_BODY_TEMPLATE = ('''Hi {first_name},
We haven’t seen any activity on your account for for at least {days_inactive} days.
To keep your account, please sign in and use the service within the next {reactivate_days} days.
If no action is taken, your account will be permanently deleted.
Need help? https://rhodecode.com/support''')
INACTIVITY_DAYS = 183
DAYS_FOR_REACTIVATION = 30
def find_inactive_users(inactive_days: int = INACTIVITY_DAYS):
log.info(f"Find users who are inactive for {inactive_days} days")
inactive_users = User.list_inactive(older_then_days=inactive_days)
log.debug(f"Found {len(inactive_users)} inactive users. Inactive user names: {[u.username for u in inactive_users]}")
has_emails = [u for u in inactive_users if u.email]
no_emails = [u for u in inactive_users if not u.email]
return has_emails, no_emails
def dump_users_to_csv(
users: List[Base],
*,
model=None,
directory: str = ".",
filename_prefix: str = "inactive_users",
sensitive_fields: set[str] = SENSITIVE_FIELDS,
) -> str | None:
"""
Dump ORM user rows into a CSV.
- Auto-discovers mapped columns (no relations) via SQLAlchemy.
- Blanks sensitive fields.
- filename_prefix: to filename_prefix will be added UTC timestamp + .csv extension
Returns the absolute file path.
"""
# 1) Determine the model and column list (skip relationships by using mapper.columns)
if not users:
log.error("No users found, nothing to dump")
return None
if not all(isinstance(u, Base) for u in users):
log.error(f"Provide SQLAlchemy model list")
return None
if model is None:
model = type(users[0])
mapper = sa_inspect(model)
column_keys = [col.key for col in mapper.columns]
# 2) Build filename with UTC timestamp
ts = datetime.now(timezone.utc)
stamp = f"{ts.strftime('%d-%m-%Y_%H:%M:%S')}"
filename = f"{filename_prefix}_{stamp}.csv"
os.makedirs(directory, exist_ok=True)
filepath = os.path.abspath(os.path.join(directory, filename))
# 3) Simple serializer for CSV-safe values
def _ser(v):
if v is None:
return ""
if isinstance(v, (datetime, date)):
return v.isoformat(sep=" ", timespec="seconds")
if isinstance(v, decimal.Decimal):
return format(v, "f")
return str(v)
sensitive = set(sensitive_fields)
try:
with open(filepath, "w", newline="", encoding="utf-8") as f:
log.info(f"Writing user data for {len(users)} user(s) to {filepath}")
writer = csv.DictWriter(f, fieldnames=column_keys, extrasaction="ignore")
writer.writeheader()
for u in users:
row = {}
for key in column_keys:
row[key] = "" if key in sensitive else _ser(getattr(u, key, None))
writer.writerow(row)
except Exception as e:
log.error(f"Failed to write users data for {filepath}: {e}")
return None
return filepath
def batch_send_email_to_inactive_users(
users: list[User],
subject: str = EMAIL_SUBJECT,
body_template: str = EMAIL_BODY_TEMPLATE,
inactive_days: int = INACTIVITY_DAYS,
days_for_reactivation: int = DAYS_FOR_REACTIVATION,
) -> tuple[list[str], list[str]]:
emails_sent = []
emails_failed = []
if not users:
log.warning("No recipients provided")
return emails_sent, emails_failed
email_config = rhodecode.CONFIG
mail_server = email_config.get("smtp_server") or None
dev_mod = str2bool(email_config.get("development_email"))
if mail_server is None and not dev_mod:
log.error(
"SMTP server information missing. Sending email failed. "
"Make sure that `smtp_server` variable is configured "
"inside the .ini file"
)
return emails_sent, emails_failed
conf_prefix = email_config.get("email_prefix", None)
prefix = f"{conf_prefix} " if conf_prefix else ""
subject = f"{prefix}{subject}"
transformed_email_confing = transform_legacy_email_config(email_config, mail_server)
extra_headers = {
"Date": formatdate(time.time())
}
mailer = get_mailer(transformed_email_confing, email_config)
users_with_emails = list(filter(lambda u: u.email, users))
log.debug(f"Total emails to send: {len(users_with_emails)}")
for u in users_with_emails:
try:
log.debug(f"Sending email to {u.email}")
body = body_template.format(
first_name=u.first_name,
days_inactive=inactive_days,
reactivate_days=days_for_reactivation
)
message = Message(
subject=subject,
sender=transformed_email_confing["default_sender"],
recipients=[u.email],
body=body,
extra_headers=extra_headers,
)
mailer.send_immediately(message)
statsd = StatsdClient.statsd
if statsd:
statsd.incr("rhodecode_email_sent_total")
emails_sent.append(u)
log.debug(f"Sent email to {u.email}")
except Exception as e:
log.error(f"Mail sending failed for recipient {u.email}", e)
emails_failed.append(u)
time.sleep(1)
return emails_sent, emails_failed
def filter_reactivated_users(csv_path: str, inactive_days: int = INACTIVITY_DAYS) -> tuple[list[User], list[User]]:
"""
takes users from the csv file, and compare them with existing users if user have been reactivated
"""
inactive_users = []
reactivated_users = []
if not os.path.exists(csv_path) or not os.path.isfile(csv_path) or not os.access(csv_path, os.R_OK):
log.error(f"File {csv_path} does not exist or is not readable")
return inactive_users, reactivated_users
ids = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f, delimiter=",")
for row in reader:
if not row["user_id"]:
log.error("Can't find user_id in csv file, skipping line")
continue
ids.append(int(row["user_id"]))
csv_users = UserModel().list(ids)
has_emails, no_emails = find_inactive_users(inactive_days)
current_inactive_users = [*has_emails, *no_emails]
current_inactive_users_ids = {u.user_id for u in current_inactive_users}
for u in csv_users:
if u.user_id in current_inactive_users_ids:
inactive_users.append(u)
else:
reactivated_users.append(u)
return inactive_users, reactivated_users
def delete_inactive_users_and_their_assets(users: List[User]) -> tuple[list[dict], list[dict]]:
deleted_users = []
failed_to_delete = []
for u in users:
log.debug(f"Deleting user {u.username}")
user_info = {
"id": u.user_id,
"username": u.username,
"email": u.email,
}
try:
UserModel().delete(u.user_id)
deleted_users.append(user_info)
except Exception as e:
log.error(f"Failed to delete user {u.username}: {e}")
failed_to_delete.append(user_info)
return deleted_users, failed_to_delete

View file

@ -716,6 +716,30 @@ class User(Base, BaseModel):
def __repr__(self):
return f"<{self.cls_name}('id={self.user_id}, username={self.username}')>"
@classmethod
def list_inactive(cls, older_then_days: int):
query = cls.query()
if not older_then_days or older_then_days <= 0:
log.warning(f"Older then the days cannot be zero or negative: {older_then_days}")
raise ValueError("Older then the days cannot be zero or negative")
cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=older_then_days)
last_activity_long_ago_or_no_activity_since_creation = (
(cls.last_activity < cutoff_date) | ((cls.last_activity == None) & (cls.created_on < cutoff_date))
)
exclude_system_users = ~cls.username.in_([
cls.GHOST_USER, cls.AI_USER, cls.DEFAULT_USER
])
query = query.filter(
exclude_system_users,
last_activity_long_ago_or_no_activity_since_creation
)
return query.all()
@hybrid_property
def email(self):
return self._email

View file

@ -77,6 +77,14 @@ class UserModel(BaseModel):
q = q.options(FromCache("sql_cache_short", f"get_users_{user_id}"))
return cls.execute(q).scalar_one_or_none()
def list(self, user_ids: list[int]):
if not user_ids:
return []
cls = self.cls
q = cls.select().where(cls.user_id.in_(user_ids))
return cls.execute(q).scalars().all()
def get_user(self, user):
return self._get_user(user)

View file

@ -37,7 +37,7 @@
<label for="rhodecode_ai_api_key">${_('Api key')}</label>
</div>
<div class="input">
${h.text('rhodecode_ai_api_key', c.api_key, size=30)}
${h.password('rhodecode_ai_api_key', c.api_key, size=30)}
</div>
</div>
<div class="field">

View file

@ -0,0 +1,110 @@
import csv
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from rhodecode.lib.rc_commands.inactive_users import find_inactive_users, filter_reactivated_users
from rhodecode.model.db import User
@pytest.mark.usefixtures("app", "user_util")
class TestFilterInactiveUsers(object):
def test_filter_inactive_users__user_with_email_never_logged_in(self, user_util):
inactive_user = user_util.create_user(username="user_to_be_removed")
user_util.create_user(username="normal_user")
created_long_ago = datetime.now() - timedelta(days=300)
inactive_user.created_on = created_long_ago
user_id = inactive_user.user_id
inactive_users_with_email, inactive_users_without_email = find_inactive_users()
assert len(inactive_users_without_email) == 0
assert len(inactive_users_with_email) == 1
assert inactive_users_with_email[0].user_id == user_id
def test_filter_inactive_users__user_without_email_never_logged_in(self, user_util):
inactive_user = user_util.create_user(username="user_to_be_removed", email=None)
user_util.create_user(username="normal_user")
created_long_ago = datetime.now() - timedelta(days=300)
inactive_user.created_on = created_long_ago
user_id = inactive_user.user_id
inactive_users_with_email, inactive_users_without_email = find_inactive_users()
assert len(inactive_users_without_email) == 1
assert len(inactive_users_with_email) == 0
assert inactive_users_without_email[0].user_id == user_id
def test_filter_inactive_users__user_with_email_last_activity_long_ago(self, user_util):
inactive_user = user_util.create_user(username="user_to_be_removed")
user_util.create_user(username="normal_user")
activity_long_ago = datetime.now() - timedelta(days=300)
inactive_user.last_activity = activity_long_ago
user_id = inactive_user.user_id
inactive_users_with_email, inactive_users_without_email = find_inactive_users()
assert len(inactive_users_without_email) == 0
assert len(inactive_users_with_email) == 1
assert inactive_users_with_email[0].user_id == user_id
def test_filter_inactive_users__user_without_email_last_activity_long_ago(self, user_util):
inactive_user = user_util.create_user(username="user_to_be_removed", email=None)
user_util.create_user(username="normal_user")
activity_long_ago = datetime.now() - timedelta(days=300)
inactive_user.last_activity = activity_long_ago
user_id = inactive_user.user_id
inactive_users_with_email, inactive_users_without_email = find_inactive_users()
assert len(inactive_users_without_email) == 1
assert len(inactive_users_with_email) == 0
assert inactive_users_without_email[0].user_id == user_id
def test_diff__previously_inactive_user_reactivated(self, user_util):
prev_inactive_user = user_util.create_user(username="user_to_be_removed")
activity_long_ago = datetime.now() - timedelta(days=300)
prev_inactive_user.last_activity = activity_long_ago
user_id = prev_inactive_user.user_id
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "data.csv"
with path.open("w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["user_id"])
w.writerows([[user_id]])
# reactivate user
inactive_user = User.get(user_id=user_id)
inactive_user.last_activity = datetime.now()
inactive_users, reactivated_users = filter_reactivated_users(str(path))
assert len(reactivated_users) == 1
assert len(inactive_users) == 0
assert reactivated_users[0].user_id == user_id
def test_diff__previously_inactive_user_remain_inactive(self, user_util):
prev_inactive_user = user_util.create_user(username="user_to_be_removed")
activity_long_ago = datetime.now() - timedelta(days=300)
prev_inactive_user.last_activity = activity_long_ago
user_id = prev_inactive_user.user_id
with tempfile.TemporaryDirectory() as td:
path = Path(td) / "data.csv"
with path.open("w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["user_id"])
w.writerows([[user_id]])
inactive_users, reactivated_users = filter_reactivated_users(str(path))
assert len(reactivated_users) == 0
assert len(inactive_users) == 1
assert inactive_users[0].user_id == user_id