diff --git a/frigate/api/defs/request/review_body.py b/frigate/api/defs/request/review_body.py index 991f190f8..6dc710035 100644 --- a/frigate/api/defs/request/review_body.py +++ b/frigate/api/defs/request/review_body.py @@ -4,3 +4,5 @@ from pydantic import BaseModel, conlist, constr class ReviewModifyMultipleBody(BaseModel): # List of string with at least one element and each element with at least one char ids: conlist(constr(min_length=1), min_length=1) + # Whether to mark items as reviewed (True) or unreviewed (False) + reviewed: bool = True diff --git a/frigate/api/review.py b/frigate/api/review.py index bddc1c0b7..efb2269a7 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -435,22 +435,27 @@ async def set_multiple_reviewed( UserReviewStatus.user_id == user_id, UserReviewStatus.review_segment == review_id, ) - # If it exists and isn’t reviewed, update it - if not review_status.has_been_reviewed: - review_status.has_been_reviewed = True + # Update based on the reviewed parameter + if review_status.has_been_reviewed != body.reviewed: + review_status.has_been_reviewed = body.reviewed review_status.save() except DoesNotExist: try: UserReviewStatus.create( user_id=user_id, review_segment=ReviewSegment.get(id=review_id), - has_been_reviewed=True, + has_been_reviewed=body.reviewed, ) except (DoesNotExist, IntegrityError): pass return JSONResponse( - content=({"success": True, "message": "Reviewed multiple items"}), + content=( + { + "success": True, + "message": f"Marked multiple items as {'reviewed' if body.reviewed else 'unreviewed'}", + } + ), status_code=200, ) diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index 731438e04..c7cc29bac 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -416,7 +416,7 @@ class TestHttpReview(BaseTestHttp): assert response.status_code == 200 response = response.json() assert response["success"] == True - assert response["message"] == "Reviewed multiple items" + assert response["message"] == "Marked multiple items as reviewed" # Verify that in DB the review segment was not changed with self.assertRaises(DoesNotExist): UserReviewStatus.get( @@ -433,7 +433,7 @@ class TestHttpReview(BaseTestHttp): assert response.status_code == 200 response_json = response.json() assert response_json["success"] == True - assert response_json["message"] == "Reviewed multiple items" + assert response_json["message"] == "Marked multiple items as reviewed" # Verify UserReviewStatus was created user_review = UserReviewStatus.get( UserReviewStatus.user_id == self.user_id, diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index a64dbf594..d502d5cc8 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -106,6 +106,7 @@ "button": { "export": "Export", "markAsReviewed": "Mark as reviewed", + "markAsUnreviewed": "Mark as unreviewed", "deleteNow": "Delete Now" } }, diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index f91f75461..54f69ba62 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -1,10 +1,11 @@ -import { FaCircleCheck } from "react-icons/fa6"; +import { FaCircleCheck, FaCircleXmark } from "react-icons/fa6"; import { useCallback, useState } from "react"; import axios from "axios"; import { Button, buttonVariants } from "../ui/button"; import { isDesktop } from "react-device-detect"; import { FaCompactDisc } from "react-icons/fa"; import { HiTrash } from "react-icons/hi"; +import { ReviewSegment } from "@/types/review"; import { AlertDialog, AlertDialogAction, @@ -20,8 +21,8 @@ import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; type ReviewActionGroupProps = { - selectedReviews: string[]; - setSelectedReviews: (ids: string[]) => void; + selectedReviews: ReviewSegment[]; + setSelectedReviews: (reviews: ReviewSegment[]) => void; onExport: (id: string) => void; pullLatestData: () => void; }; @@ -36,15 +37,24 @@ export default function ReviewActionGroup({ setSelectedReviews([]); }, [setSelectedReviews]); - const onMarkAsReviewed = useCallback(async () => { - await axios.post(`reviews/viewed`, { ids: selectedReviews }); + const allReviewed = selectedReviews.every( + (review) => review.has_been_reviewed, + ); + + const onToggleReviewed = useCallback(async () => { + const ids = selectedReviews.map((review) => review.id); + await axios.post(`reviews/viewed`, { + ids, + reviewed: !allReviewed, + }); setSelectedReviews([]); pullLatestData(); - }, [selectedReviews, setSelectedReviews, pullLatestData]); + }, [selectedReviews, setSelectedReviews, pullLatestData, allReviewed]); const onDelete = useCallback(() => { + const ids = selectedReviews.map((review) => review.id); axios - .post(`reviews/delete`, { ids: selectedReviews }) + .post(`reviews/delete`, { ids }) .then((resp) => { if (resp.status === 200) { toast.success(t("recording.confirmDelete.toast.success"), { @@ -140,7 +150,7 @@ export default function ReviewActionGroup({ aria-label={t("recording.button.export")} size="sm" onClick={() => { - onExport(selectedReviews[0]); + onExport(selectedReviews[0].id); onClearSelected(); }} > @@ -154,14 +164,24 @@ export default function ReviewActionGroup({ )} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index d477f5693..656d5c7c6 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -356,6 +356,7 @@ export default function Events() { if (itemsToMarkReviewed.length > 0) { await axios.post(`reviews/viewed`, { ids: itemsToMarkReviewed, + reviewed: true, }); reloadData(); } @@ -365,7 +366,10 @@ export default function Events() { const markItemAsReviewed = useCallback( async (review: ReviewSegment) => { - const resp = await axios.post(`reviews/viewed`, { ids: [review.id] }); + const resp = await axios.post(`reviews/viewed`, { + ids: [review.id], + reviewed: true, + }); if (resp.status == 200) { updateSegments( diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index ca77b22c7..83c080555 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -135,11 +135,11 @@ export default function EventView({ // review interaction - const [selectedReviews, setSelectedReviews] = useState([]); + const [selectedReviews, setSelectedReviews] = useState([]); const onSelectReview = useCallback( (review: ReviewSegment, ctrl: boolean) => { if (selectedReviews.length > 0 || ctrl) { - const index = selectedReviews.indexOf(review.id); + const index = selectedReviews.findIndex((r) => r.id === review.id); if (index != -1) { if (selectedReviews.length == 1) { @@ -153,7 +153,7 @@ export default function EventView({ } } else { const copy = [...selectedReviews]; - copy.push(review.id); + copy.push(review); setSelectedReviews(copy); } } else { @@ -175,7 +175,7 @@ export default function EventView({ } if (selectedReviews.length < currentReviewItems.length) { - setSelectedReviews(currentReviewItems.map((seg) => seg.id)); + setSelectedReviews(currentReviewItems); } else { setSelectedReviews([]); } @@ -429,7 +429,7 @@ type DetectionReviewProps = { currentItems: ReviewSegment[] | null; itemsToReview?: number; relevantPreviews?: Preview[]; - selectedReviews: string[]; + selectedReviews: ReviewSegment[]; severity: ReviewSeverity; filter?: ReviewFilter; timeRange: { before: number; after: number }; @@ -439,7 +439,7 @@ type DetectionReviewProps = { markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; onSelectAllReviews: () => void; - setSelectedReviews: (reviewIds: string[]) => void; + setSelectedReviews: (reviews: ReviewSegment[]) => void; pullLatestData: () => void; }; function DetectionReview({ @@ -667,7 +667,7 @@ function DetectionReview({ case "r": if (selectedReviews.length > 0 && !modifiers.repeat) { currentItems?.forEach((item) => { - if (selectedReviews.includes(item.id)) { + if (selectedReviews.some((r) => r.id === item.id)) { item.has_been_reviewed = true; markItemAsReviewed(item); } @@ -723,7 +723,7 @@ function DetectionReview({ > {!loading && currentItems ? currentItems.map((value) => { - const selected = selectedReviews.includes(value.id); + const selected = selectedReviews.some((r) => r.id === value.id); return (