234 lines
7.9 KiB
Python
234 lines
7.9 KiB
Python
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("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
|