Add ability to mark review items as unreviewed (#20446)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* new body param

* use new body param in endpoint

* explicitly use new param in frontend endpoint

* use reviewsegment as type instead of list of strings

* add toggle function to mark as unreviewed when all selected are reviewed

* i18n

* fix tests
This commit is contained in:
Josh Hawkins 2025-10-12 08:10:56 -05:00 committed by GitHub
parent a2ad77c36e
commit 6d5098a0c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 60 additions and 28 deletions

View File

@ -4,3 +4,5 @@ from pydantic import BaseModel, conlist, constr
class ReviewModifyMultipleBody(BaseModel): class ReviewModifyMultipleBody(BaseModel):
# List of string with at least one element and each element with at least one char # 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) ids: conlist(constr(min_length=1), min_length=1)
# Whether to mark items as reviewed (True) or unreviewed (False)
reviewed: bool = True

View File

@ -435,22 +435,27 @@ async def set_multiple_reviewed(
UserReviewStatus.user_id == user_id, UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id, UserReviewStatus.review_segment == review_id,
) )
# If it exists and isnt reviewed, update it # Update based on the reviewed parameter
if not review_status.has_been_reviewed: if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = True review_status.has_been_reviewed = body.reviewed
review_status.save() review_status.save()
except DoesNotExist: except DoesNotExist:
try: try:
UserReviewStatus.create( UserReviewStatus.create(
user_id=user_id, user_id=user_id,
review_segment=ReviewSegment.get(id=review_id), review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=True, has_been_reviewed=body.reviewed,
) )
except (DoesNotExist, IntegrityError): except (DoesNotExist, IntegrityError):
pass pass
return JSONResponse( 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, status_code=200,
) )

View File

@ -416,7 +416,7 @@ class TestHttpReview(BaseTestHttp):
assert response.status_code == 200 assert response.status_code == 200
response = response.json() response = response.json()
assert response["success"] == True 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 # Verify that in DB the review segment was not changed
with self.assertRaises(DoesNotExist): with self.assertRaises(DoesNotExist):
UserReviewStatus.get( UserReviewStatus.get(
@ -433,7 +433,7 @@ class TestHttpReview(BaseTestHttp):
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
assert response_json["success"] == True 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 # Verify UserReviewStatus was created
user_review = UserReviewStatus.get( user_review = UserReviewStatus.get(
UserReviewStatus.user_id == self.user_id, UserReviewStatus.user_id == self.user_id,

View File

@ -106,6 +106,7 @@
"button": { "button": {
"export": "Export", "export": "Export",
"markAsReviewed": "Mark as reviewed", "markAsReviewed": "Mark as reviewed",
"markAsUnreviewed": "Mark as unreviewed",
"deleteNow": "Delete Now" "deleteNow": "Delete Now"
} }
}, },

View File

@ -1,10 +1,11 @@
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck, FaCircleXmark } from "react-icons/fa6";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import axios from "axios"; import axios from "axios";
import { Button, buttonVariants } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi"; import { HiTrash } from "react-icons/hi";
import { ReviewSegment } from "@/types/review";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -20,8 +21,8 @@ import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
type ReviewActionGroupProps = { type ReviewActionGroupProps = {
selectedReviews: string[]; selectedReviews: ReviewSegment[];
setSelectedReviews: (ids: string[]) => void; setSelectedReviews: (reviews: ReviewSegment[]) => void;
onExport: (id: string) => void; onExport: (id: string) => void;
pullLatestData: () => void; pullLatestData: () => void;
}; };
@ -36,15 +37,24 @@ export default function ReviewActionGroup({
setSelectedReviews([]); setSelectedReviews([]);
}, [setSelectedReviews]); }, [setSelectedReviews]);
const onMarkAsReviewed = useCallback(async () => { const allReviewed = selectedReviews.every(
await axios.post(`reviews/viewed`, { ids: selectedReviews }); (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([]); setSelectedReviews([]);
pullLatestData(); pullLatestData();
}, [selectedReviews, setSelectedReviews, pullLatestData]); }, [selectedReviews, setSelectedReviews, pullLatestData, allReviewed]);
const onDelete = useCallback(() => { const onDelete = useCallback(() => {
const ids = selectedReviews.map((review) => review.id);
axios axios
.post(`reviews/delete`, { ids: selectedReviews }) .post(`reviews/delete`, { ids })
.then((resp) => { .then((resp) => {
if (resp.status === 200) { if (resp.status === 200) {
toast.success(t("recording.confirmDelete.toast.success"), { toast.success(t("recording.confirmDelete.toast.success"), {
@ -140,7 +150,7 @@ export default function ReviewActionGroup({
aria-label={t("recording.button.export")} aria-label={t("recording.button.export")}
size="sm" size="sm"
onClick={() => { onClick={() => {
onExport(selectedReviews[0]); onExport(selectedReviews[0].id);
onClearSelected(); onClearSelected();
}} }}
> >
@ -154,14 +164,24 @@ export default function ReviewActionGroup({
)} )}
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
aria-label={t("recording.button.markAsReviewed")} aria-label={
allReviewed
? t("recording.button.markAsUnreviewed")
: t("recording.button.markAsReviewed")
}
size="sm" size="sm"
onClick={onMarkAsReviewed} onClick={onToggleReviewed}
> >
{allReviewed ? (
<FaCircleXmark className="text-secondary-foreground" />
) : (
<FaCircleCheck className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
)}
{isDesktop && ( {isDesktop && (
<div className="text-primary"> <div className="text-primary">
{t("recording.button.markAsReviewed")} {allReviewed
? t("recording.button.markAsUnreviewed")
: t("recording.button.markAsReviewed")}
</div> </div>
)} )}
</Button> </Button>

View File

@ -356,6 +356,7 @@ export default function Events() {
if (itemsToMarkReviewed.length > 0) { if (itemsToMarkReviewed.length > 0) {
await axios.post(`reviews/viewed`, { await axios.post(`reviews/viewed`, {
ids: itemsToMarkReviewed, ids: itemsToMarkReviewed,
reviewed: true,
}); });
reloadData(); reloadData();
} }
@ -365,7 +366,10 @@ export default function Events() {
const markItemAsReviewed = useCallback( const markItemAsReviewed = useCallback(
async (review: ReviewSegment) => { 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) { if (resp.status == 200) {
updateSegments( updateSegments(

View File

@ -135,11 +135,11 @@ export default function EventView({
// review interaction // review interaction
const [selectedReviews, setSelectedReviews] = useState<string[]>([]); const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]);
const onSelectReview = useCallback( const onSelectReview = useCallback(
(review: ReviewSegment, ctrl: boolean) => { (review: ReviewSegment, ctrl: boolean) => {
if (selectedReviews.length > 0 || ctrl) { if (selectedReviews.length > 0 || ctrl) {
const index = selectedReviews.indexOf(review.id); const index = selectedReviews.findIndex((r) => r.id === review.id);
if (index != -1) { if (index != -1) {
if (selectedReviews.length == 1) { if (selectedReviews.length == 1) {
@ -153,7 +153,7 @@ export default function EventView({
} }
} else { } else {
const copy = [...selectedReviews]; const copy = [...selectedReviews];
copy.push(review.id); copy.push(review);
setSelectedReviews(copy); setSelectedReviews(copy);
} }
} else { } else {
@ -175,7 +175,7 @@ export default function EventView({
} }
if (selectedReviews.length < currentReviewItems.length) { if (selectedReviews.length < currentReviewItems.length) {
setSelectedReviews(currentReviewItems.map((seg) => seg.id)); setSelectedReviews(currentReviewItems);
} else { } else {
setSelectedReviews([]); setSelectedReviews([]);
} }
@ -429,7 +429,7 @@ type DetectionReviewProps = {
currentItems: ReviewSegment[] | null; currentItems: ReviewSegment[] | null;
itemsToReview?: number; itemsToReview?: number;
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
selectedReviews: string[]; selectedReviews: ReviewSegment[];
severity: ReviewSeverity; severity: ReviewSeverity;
filter?: ReviewFilter; filter?: ReviewFilter;
timeRange: { before: number; after: number }; timeRange: { before: number; after: number };
@ -439,7 +439,7 @@ type DetectionReviewProps = {
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
onSelectAllReviews: () => void; onSelectAllReviews: () => void;
setSelectedReviews: (reviewIds: string[]) => void; setSelectedReviews: (reviews: ReviewSegment[]) => void;
pullLatestData: () => void; pullLatestData: () => void;
}; };
function DetectionReview({ function DetectionReview({
@ -667,7 +667,7 @@ function DetectionReview({
case "r": case "r":
if (selectedReviews.length > 0 && !modifiers.repeat) { if (selectedReviews.length > 0 && !modifiers.repeat) {
currentItems?.forEach((item) => { currentItems?.forEach((item) => {
if (selectedReviews.includes(item.id)) { if (selectedReviews.some((r) => r.id === item.id)) {
item.has_been_reviewed = true; item.has_been_reviewed = true;
markItemAsReviewed(item); markItemAsReviewed(item);
} }
@ -723,7 +723,7 @@ function DetectionReview({
> >
{!loading && currentItems {!loading && currentItems
? currentItems.map((value) => { ? currentItems.map((value) => {
const selected = selectedReviews.includes(value.id); const selected = selectedReviews.some((r) => r.id === value.id);
return ( return (
<div <div