diff --git a/backend/apps/library/tests/test_uploads.py b/backend/apps/library/tests/test_uploads.py index 4869235..d122946 100644 --- a/backend/apps/library/tests/test_uploads.py +++ b/backend/apps/library/tests/test_uploads.py @@ -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) diff --git a/backend/apps/library/uploads.py b/backend/apps/library/uploads.py index 63c0899..7ec5a03 100644 --- a/backend/apps/library/uploads.py +++ b/backend/apps/library/uploads.py @@ -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) diff --git a/frontend/src/features/upload/BulkRateModal.tsx b/frontend/src/features/upload/BulkRateModal.tsx new file mode 100644 index 0000000..00ea59e --- /dev/null +++ b/frontend/src/features/upload/BulkRateModal.tsx @@ -0,0 +1,228 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Check, Video, X } from "lucide-react"; +import { useState } from "react"; + +import { Button, Spinner } from "@/components/ui"; +import { api, errorMessage } from "@/lib/api"; +import { cn } from "@/lib/cn"; +import type { TempUpload } from "@/lib/types"; +import { toast } from "@/store/toasts"; + +const RATING_OPTIONS = [ + { + value: "s", + label: "Safe", + active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green", + }, + { + value: "q", + label: "Questionable", + active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach", + }, + { + value: "e", + label: "Explicit", + active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red", + }, +]; + +interface BulkResult { + resolved: string[]; + errors: { temp_id: string; error: string }[]; +} + +/** + * Bulk-rating tool for the Pending & Unmatched column: pick a rating, tick + * every thumbnail that belongs to it, and they all move into the library as + * custom entries in one request. + */ +export function BulkRateModal({ + uploads, + onClose, +}: { + uploads: TempUpload[]; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const [rating, setRating] = useState(null); + const [selected, setSelected] = useState>(new Set()); + + const mutation = useMutation({ + mutationFn: (payload: { temp_ids: string[]; rating: string }) => + api("/api/uploads/resolve-bulk/", { + method: "POST", + json: payload, + }), + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: ["uploads"] }); + void queryClient.invalidateQueries({ queryKey: ["files"] }); + if (result.resolved.length > 0) { + toast.ok(`Moved ${result.resolved.length} upload(s) into the library.`); + } + if (result.errors.length > 0) { + toast.error( + `${result.errors.length} could not be resolved: ${result.errors[0].error}`, + ); + } + onClose(); + }, + onError: (error) => toast.error(errorMessage(error)), + }); + + function toggle(id: string) { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( +
+
event.stopPropagation()} + > +
+
+

+ Rate unmatched uploads +

+

+ Pick a rating, tick every file that belongs to it, and they move + into the library in one go ({uploads.length} pending). +

+
+ +
+ +
+ {RATING_OPTIONS.map((option) => ( + + ))} + + + + +
+ +
+ {uploads.length === 0 ? ( +

+ Nothing pending. +

+ ) : ( +
+ {uploads.map((temp) => { + const isSelected = selected.has(temp.temp_id); + const preview = temp.preview_url ?? temp.file_url; + return ( + + ); + })} +
+ )} +
+ +
+ + {selected.size} selected + {rating ? "" : " · pick a rating"} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/features/upload/UploadPage.tsx b/frontend/src/features/upload/UploadPage.tsx index 87ca2e2..eb0b7a3 100644 --- a/frontend/src/features/upload/UploadPage.tsx +++ b/frontend/src/features/upload/UploadPage.tsx @@ -1,13 +1,13 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, + ListChecks, UploadCloud, Video, X, } from "lucide-react"; import { useEffect, - useMemo, useRef, useState, type DragEvent, @@ -16,6 +16,7 @@ import { import { Link } from "react-router-dom"; import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; +import { BulkRateModal } from "@/features/upload/BulkRateModal"; import { api, authHeaders, apiUrl, errorMessage, uploadFile } from "@/lib/api"; import { cn } from "@/lib/cn"; import { @@ -39,14 +40,6 @@ type UploadStatus = "queued" | "uploading" | "done" | "error"; /** How many files upload at once; big batches were painfully serial. */ const UPLOAD_CONCURRENCY = 3; -/** Display order: the file being uploaded stays at the top of the grid. */ -const STATUS_ORDER: Record = { - uploading: 0, - queued: 1, - error: 2, - done: 3, -}; - interface UploadEntry { id: string; file: File; @@ -639,6 +632,7 @@ export default function UploadPage() { const [processing, setProcessing] = useState(false); const [checkingIds, setCheckingIds] = useState([]); const [modalFor, setModalFor] = useState(null); + const [bulkOpen, setBulkOpen] = useState(false); useEffect(() => { const urls = objectUrls.current; @@ -661,13 +655,10 @@ export default function UploadPage() { (temp) => temp.status === "completed" || temp.status === "error", ); - // Active work first so a long batch never hides the file in progress. - const orderedEntries = useMemo( - () => - [...entries].sort( - (a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status], - ), - [entries], + // Active work stays in insertion order (the grid must not reshuffle while + // you scroll it); the in-flight files get their own strip above it. + const uploadingEntries = entries.filter( + (entry) => entry.status === "uploading", ); const uploadedCount = entries.filter((entry) => entry.status === "done").length; @@ -1023,8 +1014,34 @@ export default function UploadPage() { {batchPercent}% -
- {orderedEntries.map((entry) => ( + {uploadingEntries.length > 0 ? ( +
+ {uploadingEntries.map((entry) => ( +
+ + {entry.file.name} + +
+
+
+ + {entry.progress}% + +
+ ))} +
+ ) : null} +
+ {entries.map((entry) => (
- {column("Pending & Unmatched", pending, "Nothing waiting.")} + {column( + "Pending & Unmatched", + pending, + "Nothing waiting.", + pending.length > 0 ? ( + + ) : null, + )} {column( "Visual Similarity Detected", visual, @@ -1107,6 +1139,10 @@ export default function UploadPage() { {modalFor ? ( setModalFor(null)} /> ) : null} + + {bulkOpen ? ( + setBulkOpen(false)} /> + ) : null}
); }