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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user