diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index 41a43bad..a5f5eaef 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -107,43 +107,7 @@ 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 = 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( + transformed_email_confing = dict( host=mail_server, port=email_config.get("smtp_port", 25), username=email_config.get("smtp_username", None), @@ -164,14 +128,42 @@ def transform_legacy_email_config(email_config: dict[Any, Any] | Any, mail_serve # sendmail_template='', ) + if extra_headers is None: + extra_headers = {} -def get_mailer(transformed_email_conf: dict[str, Any], original_email_conf: dict[str, Any]) -> Mailer | DebugMailer: - log = get_logger(get_mailer) + 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: 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 deleted file mode 100644 index b50aa6de..00000000 --- a/rhodecode/lib/rc_commands/inactive_users.py +++ /dev/null @@ -1,236 +0,0 @@ -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 be2c1cef..b71b41f7 100644 --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -716,34 +716,6 @@ 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) - - never_logged_in = ((cls.last_login == None) & (cls.last_activity == None) & (cls.created_on < cutoff_date)) - 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, - or_( - never_logged_in, - 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 b303b50d..c367b3ed 100644 --- a/rhodecode/model/user.py +++ b/rhodecode/model/user.py @@ -77,14 +77,6 @@ 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)