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

@ -250,6 +250,8 @@ def events(params: EventsQueryParams = Depends()):
order_by = Event.start_time.desc() order_by = Event.start_time.desc()
else: else:
order_by = Event.start_time.desc() order_by = Event.start_time.desc()
else:
order_by = Event.start_time.desc()
events = ( events = (
Event.select(*selected_columns) Event.select(*selected_columns)
@ -582,13 +584,11 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
processed_events.append(processed_event) processed_events.append(processed_event)
# Sort by search distance if search_results are available, otherwise by start_time as default if (sort is None or sort == "relevance") and search_results:
if search_results:
processed_events.sort(key=lambda x: x.get("search_distance", float("inf"))) processed_events.sort(key=lambda x: x.get("search_distance", float("inf")))
else: elif min_score is not None and max_score is not None and sort == "score_asc":
if sort == "score_asc":
processed_events.sort(key=lambda x: x["score"]) processed_events.sort(key=lambda x: x["score"])
elif sort == "score_desc": 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) processed_events.sort(key=lambda x: x["score"], reverse=True)
elif sort == "date_asc": elif sort == "date_asc":
processed_events.sort(key=lambda x: x["start_time"]) processed_events.sort(key=lambda x: x["start_time"])

View File

@ -26,6 +26,7 @@ from frigate.api.defs.review_responses import (
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.models import Recordings, ReviewSegment from frigate.models import Recordings, ReviewSegment
from frigate.review.maintainer import SeverityEnum
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -161,7 +162,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "alert"), (ReviewSegment.severity == SeverityEnum.alert),
ReviewSegment.has_been_reviewed, ReviewSegment.has_been_reviewed,
) )
], ],
@ -173,7 +174,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "detection"), (ReviewSegment.severity == SeverityEnum.detection),
ReviewSegment.has_been_reviewed, ReviewSegment.has_been_reviewed,
) )
], ],
@ -185,7 +186,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "alert"), (ReviewSegment.severity == SeverityEnum.alert),
1, 1,
) )
], ],
@ -197,7 +198,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "detection"), (ReviewSegment.severity == SeverityEnum.detection),
1, 1,
) )
], ],
@ -230,6 +231,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
label_clause = reduce(operator.or_, label_clauses) label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause)) clauses.append((label_clause))
day_in_seconds = 60 * 60 * 24
last_month = ( last_month = (
ReviewSegment.select( ReviewSegment.select(
fn.strftime( fn.strftime(
@ -246,7 +248,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "alert"), (ReviewSegment.severity == SeverityEnum.alert),
ReviewSegment.has_been_reviewed, ReviewSegment.has_been_reviewed,
) )
], ],
@ -258,7 +260,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "detection"), (ReviewSegment.severity == SeverityEnum.detection),
ReviewSegment.has_been_reviewed, ReviewSegment.has_been_reviewed,
) )
], ],
@ -270,7 +272,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "alert"), (ReviewSegment.severity == SeverityEnum.alert),
1, 1,
) )
], ],
@ -282,7 +284,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == "detection"), (ReviewSegment.severity == SeverityEnum.detection),
1, 1,
) )
], ],
@ -292,7 +294,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
) )
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.group_by( .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()) .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.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig 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.review.maintainer import SeverityEnum
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
@ -146,17 +146,35 @@ class BaseTestHttp(unittest.TestCase):
def insert_mock_review_segment( def insert_mock_review_segment(
self, self,
id: str, id: str,
start_time: datetime.datetime = datetime.datetime.now().timestamp(), start_time: float = datetime.datetime.now().timestamp(),
end_time: datetime.datetime = datetime.datetime.now().timestamp() + 20, end_time: float = datetime.datetime.now().timestamp() + 20,
severity: SeverityEnum = SeverityEnum.alert,
has_been_reviewed: bool = False,
) -> Event: ) -> Event:
"""Inserts a basic event model with a given id.""" """Inserts a review segment model with a given id."""
return ReviewSegment.insert( return ReviewSegment.insert(
id=id, id=id,
camera="front_door", camera="front_door",
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
has_been_reviewed=False, has_been_reviewed=has_been_reviewed,
severity=SeverityEnum.alert, severity=severity,
thumb_path=False, thumb_path=False,
data={}, data={},
).execute() ).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 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 from frigate.test.http_api.base_http_test import BaseTestHttp
class TestHttpReview(BaseTestHttp): class TestHttpReview(BaseTestHttp):
def setUp(self): 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 # 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): def test_get_review_no_filters_no_matches(self):
app = super().create_app() now = datetime.now().timestamp()
now = datetime.datetime.now().timestamp()
with TestClient(app) as client: with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now, now + 2) super().insert_mock_review_segment("123456.random", now, now + 2)
reviews_response = client.get("/review") response = client.get("/review")
assert reviews_response.status_code == 200 assert response.status_code == 200
reviews_in_response = reviews_response.json() response_json = response.json()
assert len(reviews_in_response) == 0 assert len(response_json) == 0
def test_get_review_no_filters(self): def test_get_review_no_filters(self):
app = super().create_app() now = datetime.now().timestamp()
now = datetime.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) super().insert_mock_review_segment("123456.random", now - 2, now - 1)
reviews_response = client.get("/review") response = client.get("/review")
assert reviews_response.status_code == 200 assert response.status_code == 200
reviews_in_response = reviews_response.json() response_json = response.json()
assert len(reviews_in_response) == 1 assert len(response_json) == 1
def test_get_review_with_time_filter_no_matches(self): def test_get_review_with_time_filter_no_matches(self):
app = super().create_app() now = datetime.now().timestamp()
now = datetime.datetime.now().timestamp()
with TestClient(app) as client: with TestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2) super().insert_mock_review_segment(id, now, now + 2)
params = { params = {
"after": now, "after": now,
"before": now + 3, "before": now + 3,
} }
reviews_response = client.get("/review", params=params) response = client.get("/review", params=params)
assert reviews_response.status_code == 200 assert response.status_code == 200
reviews_in_response = reviews_response.json() response_json = response.json()
assert len(reviews_in_response) == 0 assert len(response_json) == 0
def test_get_review_with_time_filter(self): def test_get_review_with_time_filter(self):
app = super().create_app() now = datetime.now().timestamp()
now = datetime.datetime.now().timestamp()
with TestClient(app) as client: with TestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2) super().insert_mock_review_segment(id, now, now + 2)
params = { params = {
"after": now - 1, "after": now - 1,
"before": now + 3, "before": now + 3,
} }
reviews_response = client.get("/review", params=params) response = client.get("/review", params=params)
assert reviews_response.status_code == 200 assert response.status_code == 200
reviews_in_response = reviews_response.json() response_json = response.json()
assert len(reviews_in_response) == 1 assert len(response_json) == 1
assert reviews_in_response[0]["id"] == id assert response_json[0]["id"] == id
def test_get_review_with_limit_filter(self): def test_get_review_with_limit_filter(self):
app = super().create_app() now = datetime.now().timestamp()
now = datetime.datetime.now().timestamp()
with TestClient(app) as client: with TestClient(self.app) as client:
id = "123456.random" id = "123456.random"
id2 = "654321.random" id2 = "654321.random"
super().insert_mock_review_segment(id, now, now + 2) super().insert_mock_review_segment(id, now, now + 2)
@ -80,17 +93,49 @@ class TestHttpReview(BaseTestHttp):
"after": now, "after": now,
"before": now + 3, "before": now + 3,
} }
reviews_response = client.get("/review", params=params) response = client.get("/review", params=params)
assert reviews_response.status_code == 200 assert response.status_code == 200
reviews_in_response = reviews_response.json() response_json = response.json()
assert len(reviews_in_response) == 1 assert len(response_json) == 1
assert reviews_in_response[0]["id"] == id2 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): def test_get_review_with_all_filters(self):
app = super().create_app() now = datetime.now().timestamp()
now = datetime.datetime.now().timestamp()
with TestClient(app) as client: with TestClient(self.app) as client:
id = "123456.random" id = "123456.random"
super().insert_mock_review_segment(id, now, now + 2) super().insert_mock_review_segment(id, now, now + 2)
params = { params = {
@ -103,8 +148,424 @@ class TestHttpReview(BaseTestHttp):
"after": now - 1, "after": now - 1,
"before": now + 3, "before": now + 3,
} }
reviews_response = client.get("/review", params=params) response = client.get("/review", params=params)
assert reviews_response.status_code == 200 assert response.status_code == 200
reviews_in_response = reviews_response.json() response_json = response.json()
assert len(reviews_in_response) == 1 assert len(response_json) == 1
assert reviews_in_response[0]["id"] == id 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, SearchFilter,
SearchFilters, SearchFilters,
SearchSource, SearchSource,
SearchSortType,
} from "@/types/search"; } from "@/types/search";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils"; 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 PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog"; import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog";
import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { CalendarRangeFilterButton } from "./CalendarFilterButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type SearchFilterGroupProps = { type SearchFilterGroupProps = {
className: string; className: string;
@ -107,6 +109,25 @@ export default function SearchFilterGroup({
[config, allLabels, allZones], [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(() => { const groups = useMemo(() => {
if (!config) { if (!config) {
return []; return [];
@ -179,6 +200,16 @@ export default function SearchFilterGroup({
filterValues={filterValues} filterValues={filterValues}
onUpdateFilter={onUpdateFilter} 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> </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, FilterType,
SavedSearchQuery, SavedSearchQuery,
SearchFilter, SearchFilter,
SearchSortType,
SearchSource, SearchSource,
} from "@/types/search"; } from "@/types/search";
import useSuggestions from "@/hooks/use-suggestions"; import useSuggestions from "@/hooks/use-suggestions";
@ -323,6 +324,9 @@ export default function InputWithTags({
case "event_id": case "event_id":
newFilters.event_id = value; newFilters.event_id = value;
break; break;
case "sort":
newFilters.sort = value as SearchSortType;
break;
default: default:
// Handle array types (cameras, labels, subLabels, zones) // Handle array types (cameras, labels, subLabels, zones)
if (!newFilters[type]) newFilters[type] = []; if (!newFilters[type]) newFilters[type] = [];

View File

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

View File

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

View File

@ -116,6 +116,7 @@ export default function Explore() {
is_submitted: searchSearchParams["is_submitted"], is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"], has_clip: searchSearchParams["has_clip"],
event_id: searchSearchParams["event_id"], event_id: searchSearchParams["event_id"],
sort: searchSearchParams["sort"],
limit: limit:
Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined, Object.keys(searchSearchParams).length == 0 ? API_LIMIT : undefined,
timezone, timezone,
@ -148,6 +149,7 @@ export default function Explore() {
is_submitted: searchSearchParams["is_submitted"], is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"], has_clip: searchSearchParams["has_clip"],
event_id: searchSearchParams["event_id"], event_id: searchSearchParams["event_id"],
sort: searchSearchParams["sort"],
timezone, timezone,
include_thumbnails: 0, include_thumbnails: 0,
}, },
@ -165,12 +167,17 @@ export default function Explore() {
const [url, params] = searchQuery; 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) { if (pageIndex > 0 && previousPageData) {
const lastDate = previousPageData[previousPageData.length - 1].start_time; const lastDate = previousPageData[previousPageData.length - 1].start_time;
return [ return [
url, 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", "zone",
"sub", "sub",
"source", "source",
"sort",
] as const; ] as const;
export type SearchFilters = (typeof SEARCH_FILTERS)[number]; export type SearchFilters = (typeof SEARCH_FILTERS)[number];
export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [ export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
@ -16,10 +17,18 @@ export const DEFAULT_SEARCH_FILTERS: SearchFilters[] = [
"zone", "zone",
"sub", "sub",
"source", "source",
"sort",
]; ];
export type SearchSource = "similarity" | "thumbnail" | "description"; export type SearchSource = "similarity" | "thumbnail" | "description";
export type SearchSortType =
| "date_asc"
| "date_desc"
| "score_asc"
| "score_desc"
| "relevance";
export type SearchResult = { export type SearchResult = {
id: string; id: string;
camera: string; camera: string;
@ -65,6 +74,7 @@ export type SearchFilter = {
time_range?: string; time_range?: string;
search_type?: SearchSource[]; search_type?: SearchSource[];
event_id?: string; event_id?: string;
sort?: SearchSortType;
}; };
export const DEFAULT_TIME_RANGE_AFTER = "00:00"; export const DEFAULT_TIME_RANGE_AFTER = "00:00";
@ -86,6 +96,7 @@ export type SearchQueryParams = {
query?: string; query?: string;
page?: number; page?: number;
time_range?: string; time_range?: string;
sort?: SearchSortType;
}; };
export type SearchQuery = [string, SearchQueryParams] | null; export type SearchQuery = [string, SearchQueryParams] | null;