Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
Rui Alves 2024-12-04 16:37:05 +00:00
commit 4a9d21cc00
10 changed files with 789 additions and 79 deletions

View File

@ -248,6 +248,8 @@ def events(params: EventsQueryParams = Depends()):
order_by = Event.start_time.asc()
elif sort == "date_desc":
order_by = Event.start_time.desc()
else:
order_by = Event.start_time.desc()
else:
order_by = Event.start_time.desc()
@ -582,19 +584,17 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
processed_events.append(processed_event)
# Sort by search distance if search_results are available, otherwise by start_time as default
if search_results:
if (sort is None or sort == "relevance") and search_results:
processed_events.sort(key=lambda x: x.get("search_distance", float("inf")))
elif min_score is not None and max_score is not None and sort == "score_asc":
processed_events.sort(key=lambda x: x["score"])
elif min_score is not None and max_score is not None and sort == "score_desc":
processed_events.sort(key=lambda x: x["score"], reverse=True)
elif sort == "date_asc":
processed_events.sort(key=lambda x: x["start_time"])
else:
if sort == "score_asc":
processed_events.sort(key=lambda x: x["score"])
elif sort == "score_desc":
processed_events.sort(key=lambda x: x["score"], reverse=True)
elif sort == "date_asc":
processed_events.sort(key=lambda x: x["start_time"])
else:
# "date_desc" default
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# "date_desc" default
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
# Limit the number of events returned
processed_events = processed_events[:limit]

View File

@ -26,6 +26,7 @@ from frigate.api.defs.review_responses import (
)
from frigate.api.defs.tags import Tags
from frigate.models import Recordings, ReviewSegment
from frigate.review.maintainer import SeverityEnum
from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__)
@ -161,7 +162,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "alert"),
(ReviewSegment.severity == SeverityEnum.alert),
ReviewSegment.has_been_reviewed,
)
],
@ -173,7 +174,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "detection"),
(ReviewSegment.severity == SeverityEnum.detection),
ReviewSegment.has_been_reviewed,
)
],
@ -185,7 +186,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "alert"),
(ReviewSegment.severity == SeverityEnum.alert),
1,
)
],
@ -197,7 +198,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "detection"),
(ReviewSegment.severity == SeverityEnum.detection),
1,
)
],
@ -230,6 +231,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
day_in_seconds = 60 * 60 * 24
last_month = (
ReviewSegment.select(
fn.strftime(
@ -246,7 +248,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "alert"),
(ReviewSegment.severity == SeverityEnum.alert),
ReviewSegment.has_been_reviewed,
)
],
@ -258,7 +260,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "detection"),
(ReviewSegment.severity == SeverityEnum.detection),
ReviewSegment.has_been_reviewed,
)
],
@ -270,7 +272,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "alert"),
(ReviewSegment.severity == SeverityEnum.alert),
1,
)
],
@ -282,7 +284,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None,
[
(
(ReviewSegment.severity == "detection"),
(ReviewSegment.severity == SeverityEnum.detection),
1,
)
],
@ -292,7 +294,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
)
.where(reduce(operator.and_, clauses))
.group_by(
(ReviewSegment.start_time + seconds_offset).cast("int") / (3600 * 24),
(ReviewSegment.start_time + seconds_offset).cast("int") / day_in_seconds,
)
.order_by(ReviewSegment.start_time.desc())
)

View File

@ -9,7 +9,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.models import Event, ReviewSegment
from frigate.models import Event, Recordings, ReviewSegment
from frigate.review.maintainer import SeverityEnum
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
@ -146,17 +146,35 @@ class BaseTestHttp(unittest.TestCase):
def insert_mock_review_segment(
self,
id: str,
start_time: datetime.datetime = datetime.datetime.now().timestamp(),
end_time: datetime.datetime = datetime.datetime.now().timestamp() + 20,
start_time: float = datetime.datetime.now().timestamp(),
end_time: float = datetime.datetime.now().timestamp() + 20,
severity: SeverityEnum = SeverityEnum.alert,
has_been_reviewed: bool = False,
) -> Event:
"""Inserts a basic event model with a given id."""
"""Inserts a review segment model with a given id."""
return ReviewSegment.insert(
id=id,
camera="front_door",
start_time=start_time,
end_time=end_time,
has_been_reviewed=False,
severity=SeverityEnum.alert,
has_been_reviewed=has_been_reviewed,
severity=severity,
thumb_path=False,
data={},
).execute()
def insert_mock_recording(
self,
id: str,
start_time: float = datetime.datetime.now().timestamp(),
end_time: float = datetime.datetime.now().timestamp() + 20,
) -> Event:
"""Inserts a recording model with a given id."""
return Recordings.insert(
id=id,
path=id,
camera="front_door",
start_time=start_time,
end_time=end_time,
duration=end_time - start_time,
).execute()

View File

@ -1,76 +1,89 @@
import datetime
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from frigate.models import Event, ReviewSegment
from frigate.models import Event, Recordings, ReviewSegment
from frigate.review.maintainer import SeverityEnum
from frigate.test.http_api.base_http_test import BaseTestHttp
class TestHttpReview(BaseTestHttp):
def setUp(self):
super().setUp([Event, ReviewSegment])
super().setUp([Event, Recordings, ReviewSegment])
self.app = super().create_app()
def _get_reviews(self, ids: list[str]):
return list(
ReviewSegment.select(ReviewSegment.id)
.where(ReviewSegment.id.in_(ids))
.execute()
)
def _get_recordings(self, ids: list[str]):
return list(
Recordings.select(Recordings.id).where(Recordings.id.in_(ids)).execute()
)
####################################################################################################################
################################### GET /review Endpoint ########################################################
####################################################################################################################
# Does not return any data point since the end time (before parameter) is not passed and the review segment end_time is 2 seconds from now
def test_get_review_no_filters_no_matches(self):
app = super().create_app()
now = datetime.datetime.now().timestamp()
now = datetime.now().timestamp()
with TestClient(app) as client:
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now, now + 2)
reviews_response = client.get("/review")
assert reviews_response.status_code == 200
reviews_in_response = reviews_response.json()
assert len(reviews_in_response) == 0
response = client.get("/review")
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 0
def test_get_review_no_filters(self):
app = super().create_app()
now = datetime.datetime.now().timestamp()
now = datetime.now().timestamp()
with TestClient(app) as client:
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now - 2, now - 1)
reviews_response = client.get("/review")
assert reviews_response.status_code == 200
reviews_in_response = reviews_response.json()
assert len(reviews_in_response) == 1
response = client.get("/review")
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
def test_get_review_with_time_filter_no_matches(self):
app = super().create_app()
now = datetime.datetime.now().timestamp()
now = datetime.now().timestamp()
with TestClient(app) as client:
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2)
params = {
"after": now,
"before": now + 3,
}
reviews_response = client.get("/review", params=params)
assert reviews_response.status_code == 200
reviews_in_response = reviews_response.json()
assert len(reviews_in_response) == 0
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 0
def test_get_review_with_time_filter(self):
app = super().create_app()
now = datetime.datetime.now().timestamp()
now = datetime.now().timestamp()
with TestClient(app) as client:
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2)
params = {
"after": now - 1,
"before": now + 3,
}
reviews_response = client.get("/review", params=params)
assert reviews_response.status_code == 200
reviews_in_response = reviews_response.json()
assert len(reviews_in_response) == 1
assert reviews_in_response[0]["id"] == id
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == id
def test_get_review_with_limit_filter(self):
app = super().create_app()
now = datetime.datetime.now().timestamp()
now = datetime.now().timestamp()
with TestClient(app) as client:
with TestClient(self.app) as client:
id = "123456.random"
id2 = "654321.random"
super().insert_mock_review_segment(id, now, now + 2)
@ -80,17 +93,49 @@ class TestHttpReview(BaseTestHttp):
"after": now,
"before": now + 3,
}
reviews_response = client.get("/review", params=params)
assert reviews_response.status_code == 200
reviews_in_response = reviews_response.json()
assert len(reviews_in_response) == 1
assert reviews_in_response[0]["id"] == id2
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == id2
def test_get_review_with_severity_filters_no_matches(self):
now = datetime.now().timestamp()
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
params = {
"severity": "detection",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == id
def test_get_review_with_severity_filters(self):
now = datetime.now().timestamp()
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2, SeverityEnum.detection)
params = {
"severity": "alert",
"after": now - 1,
"before": now + 3,
}
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 0
def test_get_review_with_all_filters(self):
app = super().create_app()
now = datetime.datetime.now().timestamp()
now = datetime.now().timestamp()
with TestClient(app) as client:
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2)
params = {
@ -103,8 +148,424 @@ class TestHttpReview(BaseTestHttp):
"after": now - 1,
"before": now + 3,
}
reviews_response = client.get("/review", params=params)
assert reviews_response.status_code == 200
reviews_in_response = reviews_response.json()
assert len(reviews_in_response) == 1
assert reviews_in_response[0]["id"] == id
response = client.get("/review", params=params)
assert response.status_code == 200
response_json = response.json()
assert len(response_json) == 1
assert response_json[0]["id"] == id
####################################################################################################################
################################### GET /review/summary Endpoint #################################################
####################################################################################################################
def test_get_review_summary_all_filters(self):
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
params = {
"cameras": "front_door",
"labels": "all",
"zones": "all",
"timezone": "utc",
}
response = client.get("/review/summary", params=params)
assert response.status_code == 200
response_json = response.json()
# e.g. '2024-11-24'
today_formatted = datetime.today().strftime("%Y-%m-%d")
expected_response = {
"last24Hours": {
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
today_formatted: {
"day": today_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
}
self.assertEqual(response_json, expected_response)
def test_get_review_summary_no_filters(self):
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
response = client.get("/review/summary")
assert response.status_code == 200
response_json = response.json()
# e.g. '2024-11-24'
today_formatted = datetime.today().strftime("%Y-%m-%d")
expected_response = {
"last24Hours": {
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
today_formatted: {
"day": today_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
}
self.assertEqual(response_json, expected_response)
def test_get_review_summary_multiple_days(self):
now = datetime.now()
five_days_ago = datetime.today() - timedelta(days=5)
with TestClient(self.app) as client:
super().insert_mock_review_segment(
"123456.random", now.timestamp() - 2, now.timestamp() - 1
)
super().insert_mock_review_segment(
"654321.random",
five_days_ago.timestamp(),
five_days_ago.timestamp() + 1,
)
response = client.get("/review/summary")
assert response.status_code == 200
response_json = response.json()
# e.g. '2024-11-24'
today_formatted = now.strftime("%Y-%m-%d")
# e.g. '2024-11-19'
five_days_ago_formatted = five_days_ago.strftime("%Y-%m-%d")
expected_response = {
"last24Hours": {
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
today_formatted: {
"day": today_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
five_days_ago_formatted: {
"day": five_days_ago_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
}
self.assertEqual(response_json, expected_response)
def test_get_review_summary_multiple_days_edge_cases(self):
now = datetime.now()
five_days_ago = datetime.today() - timedelta(days=5)
twenty_days_ago = datetime.today() - timedelta(days=20)
one_month_ago = datetime.today() - timedelta(days=30)
one_month_ago_ts = one_month_ago.timestamp()
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now.timestamp())
super().insert_mock_review_segment(
"123457.random", five_days_ago.timestamp()
)
super().insert_mock_review_segment(
"123458.random",
twenty_days_ago.timestamp(),
None,
SeverityEnum.detection,
)
# One month ago plus 5 seconds fits within the condition (review.start_time > month_ago). Assuming that the endpoint does not take more than 5 seconds to be invoked
super().insert_mock_review_segment(
"123459.random",
one_month_ago_ts + 5,
None,
SeverityEnum.detection,
)
# This won't appear in the output since it's not within last month start_time clause (review.start_time > month_ago)
super().insert_mock_review_segment("123450.random", one_month_ago_ts)
response = client.get("/review/summary")
assert response.status_code == 200
response_json = response.json()
# e.g. '2024-11-24'
today_formatted = now.strftime("%Y-%m-%d")
# e.g. '2024-11-19'
five_days_ago_formatted = five_days_ago.strftime("%Y-%m-%d")
# e.g. '2024-11-04'
twenty_days_ago_formatted = twenty_days_ago.strftime("%Y-%m-%d")
# e.g. '2024-10-24'
one_month_ago_formatted = one_month_ago.strftime("%Y-%m-%d")
expected_response = {
"last24Hours": {
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
today_formatted: {
"day": today_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
five_days_ago_formatted: {
"day": five_days_ago_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
twenty_days_ago_formatted: {
"day": twenty_days_ago_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 0,
"total_detection": 1,
},
one_month_ago_formatted: {
"day": one_month_ago_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 0,
"total_detection": 1,
},
}
self.assertEqual(response_json, expected_response)
def test_get_review_summary_multiple_in_same_day(self):
now = datetime.now()
five_days_ago = datetime.today() - timedelta(days=5)
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now.timestamp())
five_days_ago_ts = five_days_ago.timestamp()
for i in range(20):
super().insert_mock_review_segment(
f"123456_{i}.random_alert",
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.alert,
)
for i in range(15):
super().insert_mock_review_segment(
f"123456_{i}.random_detection",
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.detection,
)
response = client.get("/review/summary")
assert response.status_code == 200
response_json = response.json()
# e.g. '2024-11-24'
today_formatted = now.strftime("%Y-%m-%d")
# e.g. '2024-11-19'
five_days_ago_formatted = five_days_ago.strftime("%Y-%m-%d")
expected_response = {
"last24Hours": {
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
today_formatted: {
"day": today_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 1,
"total_detection": 0,
},
five_days_ago_formatted: {
"day": five_days_ago_formatted,
"reviewed_alert": 0,
"reviewed_detection": 0,
"total_alert": 20,
"total_detection": 15,
},
}
self.assertEqual(response_json, expected_response)
def test_get_review_summary_multiple_in_same_day_with_reviewed(self):
five_days_ago = datetime.today() - timedelta(days=5)
with TestClient(self.app) as client:
five_days_ago_ts = five_days_ago.timestamp()
for i in range(10):
super().insert_mock_review_segment(
f"123456_{i}.random_alert_not_reviewed",
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.alert,
False,
)
for i in range(10):
super().insert_mock_review_segment(
f"123456_{i}.random_alert_reviewed",
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.alert,
True,
)
for i in range(10):
super().insert_mock_review_segment(
f"123456_{i}.random_detection_not_reviewed",
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.detection,
False,
)
for i in range(5):
super().insert_mock_review_segment(
f"123456_{i}.random_detection_reviewed",
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.detection,
True,
)
response = client.get("/review/summary")
assert response.status_code == 200
response_json = response.json()
# e.g. '2024-11-19'
five_days_ago_formatted = five_days_ago.strftime("%Y-%m-%d")
expected_response = {
"last24Hours": {
"reviewed_alert": None,
"reviewed_detection": None,
"total_alert": None,
"total_detection": None,
},
five_days_ago_formatted: {
"day": five_days_ago_formatted,
"reviewed_alert": 10,
"reviewed_detection": 5,
"total_alert": 20,
"total_detection": 15,
},
}
self.assertEqual(response_json, expected_response)
####################################################################################################################
################################### POST reviews/viewed Endpoint ################################################
####################################################################################################################
def test_post_reviews_viewed_no_body(self):
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
response = client.post("/reviews/viewed")
# Missing ids
assert response.status_code == 422
def test_post_reviews_viewed_no_body_ids(self):
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
body = {"ids": [""]}
response = client.post("/reviews/viewed", json=body)
# Missing ids
assert response.status_code == 422
def test_post_reviews_viewed_non_existent_id(self):
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id)
body = {"ids": ["1"]}
response = client.post("/reviews/viewed", json=body)
assert response.status_code == 200
response = response.json()
assert response["success"] == True
assert response["message"] == "Reviewed multiple items"
# Verify that in DB the review segment was not changed
review_segment_in_db = (
ReviewSegment.select(ReviewSegment.has_been_reviewed)
.where(ReviewSegment.id == id)
.get()
)
assert review_segment_in_db.has_been_reviewed == False
def test_post_reviews_viewed(self):
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id)
body = {"ids": [id]}
response = client.post("/reviews/viewed", json=body)
assert response.status_code == 200
response = response.json()
assert response["success"] == True
assert response["message"] == "Reviewed multiple items"
# Verify that in DB the review segment was changed
review_segment_in_db = (
ReviewSegment.select(ReviewSegment.has_been_reviewed)
.where(ReviewSegment.id == id)
.get()
)
assert review_segment_in_db.has_been_reviewed == True
####################################################################################################################
################################### POST reviews/delete Endpoint ################################################
####################################################################################################################
def test_post_reviews_delete_no_body(self):
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
response = client.post("/reviews/delete")
# Missing ids
assert response.status_code == 422
def test_post_reviews_delete_no_body_ids(self):
with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random")
body = {"ids": [""]}
response = client.post("/reviews/delete", json=body)
# Missing ids
assert response.status_code == 422
def test_post_reviews_delete_non_existent_id(self):
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id)
body = {"ids": ["1"]}
response = client.post("/reviews/delete", json=body)
assert response.status_code == 200
response_json = response.json()
assert response_json["success"] == True
assert response_json["message"] == "Delete reviews"
# Verify that in DB the review segment was not deleted
review_ids_in_db_after = self._get_reviews([id])
assert len(review_ids_in_db_after) == 1
assert review_ids_in_db_after[0].id == id
def test_post_reviews_delete(self):
with TestClient(self.app) as client:
id = "123456.random"
super().insert_mock_review_segment(id)
body = {"ids": [id]}
response = client.post("/reviews/delete", json=body)
assert response.status_code == 200
response_json = response.json()
assert response_json["success"] == True
assert response_json["message"] == "Delete reviews"
# Verify that in DB the review segment was deleted
review_ids_in_db_after = self._get_reviews([id])
assert len(review_ids_in_db_after) == 0
def test_post_reviews_delete_many(self):
with TestClient(self.app) as client:
ids = ["123456.random", "654321.random"]
for id in ids:
super().insert_mock_review_segment(id)
super().insert_mock_recording(id)
review_ids_in_db_before = self._get_reviews(ids)
recordings_ids_in_db_before = self._get_recordings(ids)
assert len(review_ids_in_db_before) == 2
assert len(recordings_ids_in_db_before) == 2
body = {"ids": ids}
response = client.post("/reviews/delete", json=body)
assert response.status_code == 200
response_json = response.json()
assert response_json["success"] == True
assert response_json["message"] == "Delete reviews"
# Verify that in DB all review segments and recordings that were passed were deleted
review_ids_in_db_after = self._get_reviews(ids)
recording_ids_in_db_after = self._get_recordings(ids)
assert len(review_ids_in_db_after) == 0
assert len(recording_ids_in_db_after) == 0

View File

@ -15,13 +15,15 @@ import {
SearchFilter,
SearchFilters,
SearchSource,
SearchSortType,
} from "@/types/search";
import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { MdLabel } from "react-icons/md";
import { MdLabel, MdSort } from "react-icons/md";
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type SearchFilterGroupProps = {
className: string;
@ -107,6 +109,25 @@ export default function SearchFilterGroup({
[config, allLabels, allZones],
);
const availableSortTypes = useMemo(() => {
const sortTypes = ["date_asc", "date_desc"];
if (filter?.min_score || filter?.max_score) {
sortTypes.push("score_desc", "score_asc");
}
if (filter?.event_id || filter?.query) {
sortTypes.push("relevance");
}
return sortTypes as SearchSortType[];
}, [filter]);
const defaultSortType = useMemo<SearchSortType>(() => {
if (filter?.query || filter?.event_id) {
return "relevance";
} else {
return "date_desc";
}
}, [filter]);
const groups = useMemo(() => {
if (!config) {
return [];
@ -179,6 +200,16 @@ export default function SearchFilterGroup({
filterValues={filterValues}
onUpdateFilter={onUpdateFilter}
/>
{filters.includes("sort") && Object.keys(filter ?? {}).length > 0 && (
<SortTypeButton
availableSortTypes={availableSortTypes ?? []}
defaultSortType={defaultSortType}
selectedSortType={filter?.sort}
updateSortType={(newSort) => {
onUpdateFilter({ ...filter, sort: newSort });
}}
/>
)}
</div>
);
}
@ -362,3 +393,176 @@ export function GeneralFilterContent({
</>
);
}
type SortTypeButtonProps = {
availableSortTypes: SearchSortType[];
defaultSortType: SearchSortType;
selectedSortType: SearchSortType | undefined;
updateSortType: (sortType: SearchSortType | undefined) => void;
};
function SortTypeButton({
availableSortTypes,
defaultSortType,
selectedSortType,
updateSortType,
}: SortTypeButtonProps) {
const [open, setOpen] = useState(false);
const [currentSortType, setCurrentSortType] = useState<
SearchSortType | undefined
>(selectedSortType as SearchSortType);
// ui
useEffect(() => {
setCurrentSortType(selectedSortType);
// only refresh when state changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSortType]);
const trigger = (
<Button
size="sm"
variant={
selectedSortType != defaultSortType && selectedSortType != undefined
? "select"
: "default"
}
className="flex items-center gap-2 capitalize"
aria-label="Labels"
>
<MdSort
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<div
className={`${selectedSortType != defaultSortType && selectedSortType != undefined ? "text-selected-foreground" : "text-primary"}`}
>
Sort
</div>
</Button>
);
const content = (
<SortTypeContent
availableSortTypes={availableSortTypes ?? []}
defaultSortType={defaultSortType}
selectedSortType={selectedSortType}
currentSortType={currentSortType}
setCurrentSortType={setCurrentSortType}
updateSortType={updateSortType}
onClose={() => setOpen(false)}
/>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName={
isDesktop
? "scrollbar-container h-auto max-h-[80dvh] overflow-y-auto"
: "max-h-[75dvh] overflow-hidden p-4"
}
open={open}
onOpenChange={(open) => {
if (!open) {
setCurrentSortType(selectedSortType);
}
setOpen(open);
}}
/>
);
}
type SortTypeContentProps = {
availableSortTypes: SearchSortType[];
defaultSortType: SearchSortType;
selectedSortType: SearchSortType | undefined;
currentSortType: SearchSortType | undefined;
updateSortType: (sort_type: SearchSortType | undefined) => void;
setCurrentSortType: (sort_type: SearchSortType | undefined) => void;
onClose: () => void;
};
export function SortTypeContent({
availableSortTypes,
defaultSortType,
selectedSortType,
currentSortType,
updateSortType,
setCurrentSortType,
onClose,
}: SortTypeContentProps) {
const sortLabels = {
date_asc: "Date (Ascending)",
date_desc: "Date (Descending)",
score_asc: "Object Score (Ascending)",
score_desc: "Object Score (Descending)",
relevance: "Relevance",
};
return (
<>
<div className="overflow-x-hidden">
<div className="my-2.5 flex flex-col gap-2.5">
<RadioGroup
value={
Array.isArray(currentSortType)
? currentSortType?.[0]
: (currentSortType ?? defaultSortType)
}
defaultValue={defaultSortType}
onValueChange={(value) =>
setCurrentSortType(value as SearchSortType)
}
className="w-full space-y-1"
>
{availableSortTypes.map((value) => (
<div className="flex flex-row gap-2">
<RadioGroupItem
key={value}
value={value}
id={`sort-${value}`}
className={
value == (currentSortType ?? defaultSortType)
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
/>
<Label
htmlFor={`sort-${value}`}
className="flex cursor-pointer items-center space-x-2"
>
<span>{sortLabels[value]}</span>
</Label>
</div>
))}
</RadioGroup>
</div>
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label="Apply"
variant="select"
onClick={() => {
if (selectedSortType != currentSortType) {
updateSortType(currentSortType);
}
onClose();
}}
>
Apply
</Button>
<Button
aria-label="Reset"
onClick={() => {
setCurrentSortType(undefined);
updateSortType(undefined);
}}
>
Reset
</Button>
</div>
</>
);
}

View File

@ -18,6 +18,7 @@ import {
FilterType,
SavedSearchQuery,
SearchFilter,
SearchSortType,
SearchSource,
} from "@/types/search";
import useSuggestions from "@/hooks/use-suggestions";
@ -323,6 +324,9 @@ export default function InputWithTags({
case "event_id":
newFilters.event_id = value;
break;
case "sort":
newFilters.sort = value as SearchSortType;
break;
default:
// Handle array types (cameras, labels, subLabels, zones)
if (!newFilters[type]) newFilters[type] = [];

View File

@ -175,7 +175,7 @@ export default function SearchFilterDialog({
time_range: undefined,
zones: undefined,
sub_labels: undefined,
search_type: ["thumbnail", "description"],
search_type: undefined,
min_score: undefined,
max_score: undefined,
has_snapshot: undefined,

View File

@ -15,7 +15,10 @@ export function useOverlayState<S>(
(value: S, replace: boolean = false) => {
const newLocationState = { ...currentLocationState };
newLocationState[key] = value;
navigate(location.pathname, { state: newLocationState, replace });
navigate(location.pathname + location.search, {
state: newLocationState,
replace,
});
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -116,6 +116,7 @@ export default function Explore() {
is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"],
event_id: searchSearchParams["event_id"],
sort: searchSearchParams["sort"],
limit:
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
timezone,
@ -148,6 +149,7 @@ export default function Explore() {
is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"],
event_id: searchSearchParams["event_id"],
sort: searchSearchParams["sort"],
timezone,
include_thumbnails: 0,
},
@ -165,12 +167,17 @@ export default function Explore() {
const [url, params] = searchQuery;
// If it's not the first page, use the last item's start_time as the 'before' parameter
const isAscending = params.sort?.includes("date_asc");
if (pageIndex > 0 && previousPageData) {
const lastDate = previousPageData[previousPageData.length - 1].start_time;
return [
url,
{ ...params, before: lastDate.toString(), limit: API_LIMIT },
{
...params,
[isAscending ? "after" : "before"]: lastDate.toString(),
limit: API_LIMIT,
},
];
}

View File

@ -6,6 +6,7 @@ const SEARCH_FILTERS = [
"zone",
"sub",
"source",
"sort",
] as const;
export type SearchFilters = (typeof SEARCH_FILTERS)[number];
export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
@ -16,10 +17,18 @@ export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
"zone",
"sub",
"source",
"sort",
];
export type SearchSource = "similarity" | "thumbnail" | "description";
export type SearchSortType =
| "date_asc"
| "date_desc"
| "score_asc"
| "score_desc"
| "relevance";
export type SearchResult = {
id: string;
camera: string;
@ -65,6 +74,7 @@ export type SearchFilter = {
time_range?: string;
search_type?: SearchSource[];
event_id?: string;
sort?: SearchSortType;
};
export const DEFAULT_TIME_RANGE_AFTER = "00:00";
@ -86,6 +96,7 @@ export type SearchQueryParams = {
query?: string;
page?: number;
time_range?: string;
sort?: SearchSortType;
};
export type SearchQuery = [string, SearchQueryParams] | null;