feat: Add media cleanup functionality with UI and backend support for unused media management

This commit is contained in:
Ross
2026-05-11 12:58:41 +01:00
parent c3131564e6
commit 8da8f27433
7 changed files with 392 additions and 3 deletions
+102
View File
@@ -2,6 +2,7 @@ import secrets
from typing import Any, Optional
import os
import re
from io import StringIO
from django.conf import settings
from django_tables2 import SingleTableMixin
@@ -48,6 +49,7 @@ from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.views.decorators.http import require_POST
from django.core.management import call_command, CommandError
from physics.models import UserAnswer as PhysicsUserAnswer
from physics.models import Exam as PhysicsExam
@@ -107,6 +109,23 @@ import psutil
def _directory_size_bytes(path: str) -> int:
"""Return recursive directory size in bytes, skipping unreadable files."""
if not path or not os.path.exists(path):
return 0
total = 0
for root, _, files in os.walk(path):
for filename in files:
file_path = os.path.join(root, filename)
try:
total += os.path.getsize(file_path)
except OSError:
continue
return total
def feedback_checker(user):
return user.groups.filter(name="feedback_checker").exists()
@@ -409,6 +428,89 @@ def logs_view(request):
)
@login_required
@user_is_cid_user_manager
def media_cleanup(request):
"""Run django-unused-media cleanup from a manager-facing maintenance page.
Scope: cid_user_manager group members and superusers.
Functionality: supports safe preview (dry-run) and confirmed deletion runs,
returning HTMX fragments for inline results.
"""
context: dict[str, Any] = {
"ran": False,
"action": "preview",
"minimum_file_age": 60,
"remove_empty_dirs": True,
"exclude": "",
}
if request.method == "POST":
action = request.POST.get("action", "preview")
dry_run = action != "cleanup"
exclude_raw = (request.POST.get("exclude") or "").strip()
exclude = [line.strip() for line in exclude_raw.splitlines() if line.strip()]
remove_empty_dirs = request.POST.get("remove_empty_dirs") == "on"
min_age_raw = (request.POST.get("minimum_file_age") or "60").strip()
try:
minimum_file_age = max(0, int(min_age_raw))
except (TypeError, ValueError):
minimum_file_age = 0
out = StringIO()
success = True
command_kwargs: dict[str, Any] = {
"dry_run": dry_run,
"no_input": True,
"interactive": False,
"minimum_file_age": minimum_file_age,
"remove_empty_dirs": remove_empty_dirs,
"verbosity": 2,
"stdout": out,
}
if exclude:
command_kwargs["exclude"] = exclude
error_message = ""
media_size_before = _directory_size_bytes(settings.MEDIA_ROOT)
media_size_after = media_size_before
try:
call_command("cleanup_unused_media", **command_kwargs)
media_size_after = _directory_size_bytes(settings.MEDIA_ROOT)
except CommandError as exc:
success = False
error_message = str(exc)
except Exception as exc: # pragma: no cover
success = False
error_message = str(exc)
output = out.getvalue().strip()
recovered_size = max(0, media_size_before - media_size_after)
context.update(
{
"ran": True,
"success": success,
"action": action,
"dry_run": dry_run,
"minimum_file_age": minimum_file_age,
"remove_empty_dirs": remove_empty_dirs,
"exclude": exclude_raw,
"output": output,
"error_message": error_message,
"media_size_before": media_size_before,
"media_size_after": media_size_after,
"recovered_size": recovered_size,
}
)
if request.htmx:
return render(request, "rad/partials/_media_cleanup_result.html", context)
return render(request, "rad/media_cleanup.html", context)
def cid_selector(request):
return render(
request,