diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index a5f5eaef..41a43bad 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -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) diff --git a/rhodecode/lib/rc_commands/inactive_users.py b/rhodecode/lib/rc_commands/inactive_users.py new file mode 100644 index 00000000..b50aa6de --- /dev/null +++ b/rhodecode/lib/rc_commands/inactive_users.py @@ -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 diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py index b71b41f7..fc4cff8c 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -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 diff --git a/rhodecode/model/user.py b/rhodecode/model/user.py index c367b3ed..b303b50d 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -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) diff --git a/rhodecode/templates/admin/settings/settings_ai.mako b/rhodecode/templates/admin/settings/settings_ai.mako index 0af08e97..6e8e31e9 100644 --- a/rhodecode/templates/admin/settings/settings_ai.mako +++ b/rhodecode/templates/admin/settings/settings_ai.mako @@ -37,7 +37,7 @@
- ${h.text('rhodecode_ai_api_key', c.api_key, size=30)} + ${h.password('rhodecode_ai_api_key', c.api_key, size=30)}
diff --git a/rhodecode/tests/lib/rc_commands/__init__.py b/rhodecode/tests/lib/rc_commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rhodecode/tests/lib/rc_commands/test_inactive_users.py b/rhodecode/tests/lib/rc_commands/test_inactive_users.py new file mode 100644 index 00000000..d4fcb813 --- /dev/null +++ b/rhodecode/tests/lib/rc_commands/test_inactive_users.py @@ -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