unrhodecode/rhodecode/lib/rc_commands/inactive_users.py
2025-11-28 11:35:34 +01:00

234 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("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