Revert "feature: implements find\notify\delete inactive users"
This reverts commit 04ba7e9b1d.
This commit is contained in:
parent
04ba7e9b1d
commit
f6d1fc8f44
4 changed files with 32 additions and 312 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue