Revert "feature: implements find\notify\delete inactive users"

This reverts commit 04ba7e9b1d.
This commit is contained in:
ievgenii.v 2025-10-23 15:46:37 +03:00
parent 04ba7e9b1d
commit f6d1fc8f44
4 changed files with 32 additions and 312 deletions

View file

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

View file

@ -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 havent 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,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

View file

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