diff --git a/frigate/api/review.py b/frigate/api/review.py index 21b468640..04e3e6dcd 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -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()) ) diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 013785692..ad1d449c5 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -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() diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index 19e1f26f8..3bd8779aa 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -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