Files
galaxy/scripts/cleanup_datasets/admin_cleanup_datasets.py
T
John Davis b0e8306de6 Merge pull request #20819 from ccoulombe/refactor/cleanup-datasets
Refactor/cleanup admin_cleanup_datasets.py script
2026-01-27 19:06:52 -05:00

309 lines
10 KiB
Python
Executable File
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.
#!/usr/bin/env python
"""
Mark datasets as deleted that are older than specified cutoff
and (optionally) with a tool_id that matches the specified search
string.
This script is useful for administrators to cleanup after users who
leave many old datasets around. It was modeled after the cleanup_datasets.py
script originally distributed with Galaxy.
Basic Usage:
admin_cleanup_datasets.py galaxy.ini -d 60 \
--template=email_template.txt
Required Arguments:
config_file - the Galaxy configuration file (galaxy.ini)
Optional Arguments:
-d --days - number of days old the dataset must be (default: 60)
--tool_id - string to search for in dataset tool_id (default: all)
--template - Mako template file to use for email notification
-i --info_only - Print results, but don't email or delete anything
-e --email_only - Email notifications, but don't delete anything
Useful for notifying users of pending deletion
--no-send - Do no send email (Default: false)
--smtp - Specify smtp server
If not specified, use smtp settings specified in config file
--fromaddr - Specify from address
If not specified, use email_from specified in config file
Email Template Variables:
cutoff - the cutoff in days
email - the users email address
datasets - a list of tuples containing 'dataset' and 'history' names
Author: Lance Parsons (lparsons@princeton.edu)
"""
import argparse
import logging
import os
import shutil
import sys
import time
from collections import defaultdict
from datetime import (
datetime,
timedelta,
)
from time import strftime
from mako.template import Template
from sqlalchemy import (
and_,
false,
select,
)
from sqlalchemy.orm import aliased
sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, "lib")))
from cleanup_datasets import CleanupDatasetsApplication
import galaxy.config
import galaxy.util
from galaxy.model import (
Dataset,
History,
HistoryDatasetAssociation,
User,
)
from galaxy.util.script import (
app_properties_from_args,
populate_config_args,
)
log = logging.getLogger()
log.setLevel(logging.INFO)
log.addHandler(logging.StreamHandler(sys.stdout))
assert sys.version_info[:2] >= (3, 6)
def main():
"""
Datasets that are older than the specified cutoff and for which the tool_id
contains the specified text will be marked as deleted in user's history and
the user will be notified by email using the specified template file.
"""
parser = argparse.ArgumentParser()
parser.add_argument(
"legacy_config",
metavar="CONFIG",
type=str,
default=None,
nargs="?",
help="config file (legacy, use --config instead)",
)
parser.add_argument("-d", "--days", dest="days", action="store", type=int, help="number of days (60)", default=60)
parser.add_argument("--tool_id", default=None, help="Text to match against tool_id. Default: match all")
parser.add_argument(
"--template",
default=None,
help="Mako Template file to use as email "
"Variables are 'cutoff' for the cutoff in days, "
"'email' for users email and "
"'datasets' which is a list of tuples "
"containing 'dataset' and 'history' names. "
"Default: admin_cleanup_deletion_template.txt",
)
parser.add_argument(
"-i",
"--info_only",
action="store_true",
dest="info_only",
help="info about the requested action",
default=False,
)
parser.add_argument(
"-e",
"--email_only",
action="store_true",
dest="email_only",
help="Send emails only, don't delete",
default=False,
)
parser.add_argument("--no-send", action="store_true", help="Do not send email", default=False)
parser.add_argument(
"--smtp", default=None, help="SMTP Server to use to send email. Default: [read from galaxy config file]"
)
parser.add_argument(
"--fromaddr", default=None, help="From address to use to send email. Default: [read from galaxy config file]"
)
populate_config_args(parser)
args = parser.parse_args()
config_override = None
if args.legacy_config:
config_override = args.legacy_config
app_properties = app_properties_from_args(args, legacy_config_override=config_override)
if args.smtp is not None:
app_properties["smtp_server"] = args.smtp
if app_properties.get("smtp_server") is None:
parser.error("SMTP Server must be specified as an option (--smtp) or in the config file (smtp_server)")
if args.fromaddr is not None:
app_properties["email_from"] = args.fromaddr
if app_properties.get("email_from") is None:
parser.error("From address must be specified as an option (--fromaddr) or in the config file (email_from)")
scriptdir = os.path.dirname(os.path.abspath(__file__))
template_file = args.template
if template_file is None:
default_template = os.path.join(scriptdir, "admin_cleanup_deletion_template.txt")
sample_template_file = f"{default_template}.sample"
if os.path.exists(default_template):
template_file = default_template
elif os.path.exists(sample_template_file):
print(f"Copying {sample_template_file} to {default_template}")
shutil.copyfile(sample_template_file, default_template)
template_file = default_template
else:
parser.error(
"Default template ({default_template}) or sample template ({sample_template_file}) not "
"found, please specify template as an option (--template)."
)
elif not os.path.exists(template_file):
parser.error(f"Specified template file ({template_file}) not found.")
config = galaxy.config.Configuration(**app_properties)
app = CleanupDatasetsApplication(config)
cutoff_time = datetime.utcnow() - timedelta(days=args.days)
now = strftime("%Y-%m-%d %H:%M:%S")
print("##########################################")
print(f"\n# {now} - Handling stuff older than {args.days} days")
if args.info_only:
print("# Displaying info only ( --info_only )\n")
elif args.email_only:
print("# Sending emails only, not deleting ( --email_only )\n")
administrative_delete_datasets(
app,
cutoff_time,
args.days,
tool_id=args.tool_id,
template_file=template_file,
config=config,
email_only=args.email_only,
info_only=args.info_only,
no_send=args.no_send,
)
app.shutdown()
sys.exit(0)
def administrative_delete_datasets(
app, cutoff_time, cutoff_days, tool_id, template_file, config, email_only=False, info_only=False, no_send=False
):
# Marks dataset history association deleted and email users
start = time.time()
session = app.sa_session
# Get HDAs older than cutoff time (ignore tool_id at this point)
hda_ids_query = (
select(HistoryDatasetAssociation.id)
.join(Dataset, isouter=True)
.where(
and_(
Dataset.deleted == false(),
HistoryDatasetAssociation.update_time < cutoff_time,
HistoryDatasetAssociation.deleted == false(),
)
)
)
# Add all datasets associated with Histories to our list
hda_ids = session.execute(hda_ids_query).scalars().all()
# Now find the tool_id that generated the dataset (even if it was copied)
tool_matched_ids = []
if tool_id is not None:
for hda_id in hda_ids:
this_tool_id = _get_tool_id_for_hda(app, hda_id)
if this_tool_id is not None and tool_id in this_tool_id:
tool_matched_ids.append(hda_id)
hda_ids = tool_matched_ids
deleted_instance_count = 0
user_notifications = defaultdict(list)
# Process each of the Dataset objects
for hda_id in hda_ids:
# Bind hda_id for current iteration
rows = session.execute(
select(User.email, HistoryDatasetAssociation.name, History.name)
.join(History)
.join(HistoryDatasetAssociation)
.where(HistoryDatasetAssociation.id == hda_id)
).all()
for email, dataset_name, history_name in rows:
user_notifications[email].append((dataset_name, history_name))
deleted_instance_count += 1
if not info_only and not email_only:
# Get the HistoryDatasetAssociation objects
hda = session.get(HistoryDatasetAssociation, hda_id)
if not hda.deleted:
# Mark the HistoryDatasetAssociation as deleted
hda.deleted = True
session.add(hda)
print(f"Marked HistoryDatasetAssociation id {hda.id} as deleted")
if not info_only and not email_only:
session.commit()
emailtemplate = Template(filename=template_file)
for email, dataset_list in user_notifications.items():
msgtext = emailtemplate.render(email=email, datasets=dataset_list, cutoff=cutoff_days)
state = "" if info_only or email_only else " DELETED"
subject = f"Galaxy Server Cleanup - {len(dataset_list)} datasets{state}"
fromaddr = config.email_from
print()
print(f"From: {fromaddr}")
print(f"To: {email}")
print(f"Subject: {subject}")
print("----------")
print(msgtext)
if not no_send and not info_only:
galaxy.util.send_mail(fromaddr, email, subject, msgtext, config)
stop = time.time()
print()
print(f"Marked {deleted_instance_count} dataset instances as deleted")
print(f"Total elapsed time: {stop - start:.3f} seconds")
print("##########################################")
def _get_tool_id_for_hda(app, hda_id):
# TODO Some datasets don't seem to have an entry in jtod or a copied_from
if hda_id is None:
return None
# Aliases for ORM‑mapped classes
Job = aliased(app.model.Job)
JTODA = aliased(app.model.JobToOutputDatasetAssociation)
session = app.sa_session
job_query = select(Job.tool_id).join(JTODA).where(JTODA.dataset_id == hda_id)
tool_id = session.execute(job_query).scalars().first()
if tool_id is not None:
return tool_id
hda = session.get(HistoryDatasetAssociation, hda_id)
return _get_tool_id_for_hda(app, hda.copied_from_history_dataset_association_id)
if __name__ == "__main__":
main()