Scrollable upload grid and bulk rating for the pending backlog

Upload board:
- the tile grid no longer re-sorts itself as files finish (that reshuffled
  the list under the cursor); it keeps insertion order, uses auto-fill tiles
  of ~150px so they hold a readable size, scrolls inside a 60vh area and no
  longer chains the page scroll (overscroll-contain);
- the files currently in flight are pinned in a small live strip above the
  grid (name, percent, bar) so progress stays visible while the grid is
  scrolled with hundreds of tiles.

Bulk rating: a 'bulk rate' button in the Pending & Unmatched header opens a
large modal with Safe/Questionable/Explicit pills, a tickable thumbnail grid
(Select all / Clear) and one confirm that moves every selected upload into
the library with that rating. Backed by POST /api/uploads/resolve-bulk/
(temp_ids + rating, own rows only): each staged file is resolved as a custom
entry (keeps its staged tags/notes), and already-completed or foreign ids are
reported per entry instead of failing the whole batch. Built for the
358-file backlog.

Tests: 4 bulk-resolve tests (resolution with the rating, input validation,
foreign ids untouched, mixed completed+pending) — full backend suite 53
green. Verified live end to end: staged a file, bulk-resolved it as 'q', saw
J-96 created with that rating, then removed the item, temp row and test
token.
This commit is contained in:
2026-09-18 21:50:22 -05:00
parent 39307cb141
commit a761def65e
4 changed files with 472 additions and 27 deletions
+130 -8
View File
@@ -1,21 +1,31 @@
"""The staged-upload list is a complete set, not a page.
The upload board partitions it into Pending / Visual similarity / Auto-uploaded
columns; with the standard 48-item pagination a 69-file batch silently lost 21
entries from the board (and from the similarity sweep that reads it back).
"""
"""Staged uploads: complete-set listing and the bulk rating tool."""
import base64
import hashlib
import json
import shutil
import tempfile
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings
from rest_framework.authtoken.models import Token
from apps.library.models import TempUpload
from apps.library.models import MediaItem, TempUpload
User = get_user_model()
# 1x1 transparent PNG so indexing/hashing has a real image to chew on.
TINY_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
def jpost(client, path, body=None):
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
class TempUploadListTests(TestCase):
def setUp(self):
@@ -73,3 +83,115 @@ class TempUploadListTests(TestCase):
def test_anonymous_cannot_list(self):
self.assertEqual(Client().get("/api/uploads/").status_code, 401)
class BulkResolveTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._tmp = tempfile.mkdtemp(prefix="j621-bulk-")
cls._watched = Path(cls._tmp) / "library"
cls._watched.mkdir(parents=True, exist_ok=True)
cls._settings = override_settings(
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
)
cls._settings.enable()
@classmethod
def tearDownClass(cls):
cls._settings.disable()
shutil.rmtree(cls._tmp, ignore_errors=True)
super().tearDownClass()
def setUp(self):
self.uploader = User.objects.create_user(
username="bulk-uploader", password="bulk-pass-123456"
)
self.uploader.role = "uploader"
self.uploader.save(update_fields=["role"])
self.other = User.objects.create_user(
username="bulk-other", password="bulk-pass-123456"
)
self.other.role = "uploader"
self.other.save(update_fields=["role"])
def api_client(self, user):
client = Client()
client.defaults["HTTP_AUTHORIZATION"] = (
f"Token {Token.objects.create(user=user).key}"
)
return client
def make_temp(self, user, label, status=TempUpload.STATUS_PENDING):
return TempUpload.objects.create(
user=user,
file=SimpleUploadedFile(f"{label}.png", TINY_PNG, content_type="image/png"),
original_filename=f"{label}.png",
md5=hashlib.md5(label.encode()).hexdigest(),
size=len(TINY_PNG),
status=status,
)
def resolve_bulk(self, client, ids, rating):
return jpost(
client,
"/api/uploads/resolve-bulk/",
{"temp_ids": [str(value) for value in ids], "rating": rating},
)
def test_resolves_selected_uploads_with_the_rating(self):
client = self.api_client(self.uploader)
first = self.make_temp(self.uploader, "one")
second = self.make_temp(self.uploader, "two")
untouched = self.make_temp(self.uploader, "three")
response = self.resolve_bulk(client, [first.id, second.id], "q")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(len(body["resolved"]), 2)
self.assertEqual(body["errors"], [])
for temp in (first, second):
temp.refresh_from_db()
self.assertEqual(temp.status, TempUpload.STATUS_COMPLETED)
self.assertIsNotNone(temp.library_item_id)
self.assertEqual(temp.library_item.rating, "q")
self.assertEqual(temp.library_item.uploaded_by_id, self.uploader.id)
untouched.refresh_from_db()
self.assertEqual(untouched.status, TempUpload.STATUS_PENDING)
def test_rejects_bad_input(self):
client = self.api_client(self.uploader)
temp = self.make_temp(self.uploader, "input")
self.assertEqual(self.resolve_bulk(client, [], "s").status_code, 400)
self.assertEqual(self.resolve_bulk(client, [temp.id], "").status_code, 400)
self.assertEqual(self.resolve_bulk(client, [temp.id], "x").status_code, 400)
self.assertEqual(
jpost(
client,
"/api/uploads/resolve-bulk/",
{"temp_ids": ["not-a-uuid"], "rating": "s"},
).status_code,
400,
)
def test_other_users_uploads_are_left_alone(self):
client = self.api_client(self.uploader)
theirs = self.make_temp(self.other, "theirs")
response = self.resolve_bulk(client, [theirs.id], "s")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["resolved"], [])
self.assertEqual(body["errors"][0]["error"], "not found")
theirs.refresh_from_db()
self.assertEqual(theirs.status, TempUpload.STATUS_PENDING)
def test_completed_uploads_report_an_error_but_others_resolve(self):
client = self.api_client(self.uploader)
done = self.make_temp(self.uploader, "done", status=TempUpload.STATUS_COMPLETED)
pending = self.make_temp(self.uploader, "pending")
response = self.resolve_bulk(client, [done.id, pending.id], "e")
body = response.json()
self.assertEqual(len(body["resolved"]), 1)
self.assertEqual(body["errors"][0]["error"], "already in the library")
self.assertEqual(MediaItem.objects.count(), 1)
+59
View File
@@ -17,6 +17,7 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import signing
from django.core.exceptions import ValidationError
from django.http import Http404
from django.utils import timezone
from rest_framework import mixins, status, viewsets
@@ -328,6 +329,64 @@ class TempUploadViewSet(
temp.refresh_from_db()
return Response(self.get_serializer(temp).data)
@action(detail=False, methods=["post"], url_path="resolve-bulk")
def resolve_bulk(self, request):
"""Move many staged uploads into the library with one rating.
The upload board's bulk tool: pick a rating, tick pending files, and
they are all resolved as custom entries (their staged tags/notes are
kept). Own rows only, like the rest of the viewset.
"""
ids = request.data.get("temp_ids")
if not isinstance(ids, list) or not ids:
return Response(
{"detail": "temp_ids must be a non-empty list."},
status=status.HTTP_400_BAD_REQUEST,
)
rating = str(request.data.get("rating") or "").strip()
if rating not in {"s", "q", "e"}:
return Response(
{"detail": "rating must be one of s, q or e."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
queryset = self.get_queryset().filter(
pk__in=[str(value) for value in ids]
)
except (ValidationError, ValueError):
return Response(
{"detail": "One or more ids are not valid upload ids."},
status=status.HTTP_400_BAD_REQUEST,
)
resolved: list[str] = []
errors: list[dict[str, str]] = []
for temp in queryset:
if temp.status == TempUpload.STATUS_COMPLETED:
errors.append(
{"temp_id": str(temp.id), "error": "already in the library"}
)
continue
temp.custom_rating = rating
temp.resolution = TempUpload.RESOLUTION_CUSTOM
temp.save(update_fields=["custom_rating", "resolution", "updated_at"])
try:
complete_temp_upload(temp)
except Exception as exc: # noqa: BLE001 - report per-file failures
logger.exception("Could not complete staged upload %s", temp.id)
temp.status = TempUpload.STATUS_ERROR
temp.save(update_fields=["status", "updated_at"])
errors.append({"temp_id": str(temp.id), "error": str(exc)})
continue
resolved.append(str(temp.id))
found = {str(temp.id) for temp in queryset}
for value in sorted({str(value) for value in ids} - found):
errors.append({"temp_id": value, "error": "not found"})
return Response({"resolved": resolved, "errors": errors})
def perform_destroy(self, instance):
if instance.file:
instance.file.delete(save=False)