mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 20:18:30 +03:00
Add time-window based recording retention
Adds ability to filter recordings by time windows after a configurable retention period. Useful for reducing storage while keeping recordings during relevant times (e.g., business hours). New config options under record.motion (and record.continuous): - days: maximum retention in days (null for unlimited, 0 for none) - hours: list of time windows like "mon-fri 07:00-18:00" - always_retain: hours to keep all recordings before filtering (default 24) Recordings associated with alerts/detections are always retained regardless of time windows.
This commit is contained in:
parent
d1f28eb8e1
commit
fa87b8830a
@ -95,6 +95,29 @@ record:
|
|||||||
|
|
||||||
Continuous recording supports different retention modes [which are described below](#what-do-the-different-retain-modes-mean)
|
Continuous recording supports different retention modes [which are described below](#what-do-the-different-retain-modes-mean)
|
||||||
|
|
||||||
|
### Time-Window Based Retention
|
||||||
|
|
||||||
|
Recordings can be filtered to only retain footage from specific time windows after an initial period.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
record:
|
||||||
|
enabled: True
|
||||||
|
motion:
|
||||||
|
days: 14
|
||||||
|
always_retain: 48
|
||||||
|
hours:
|
||||||
|
- "mon-fri 07:00-23:00"
|
||||||
|
- "sat-sun 10:00-18:00"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported formats for `hours`:
|
||||||
|
- `"07:00-23:00"` - all days
|
||||||
|
- `"mon-fri 07:00-18:00"` - day range
|
||||||
|
- `"sat 10:00-16:00"` - single day
|
||||||
|
- `"22:00-06:00"` - overnight
|
||||||
|
|
||||||
|
Alerts and detections are always retained regardless of time windows.
|
||||||
|
|
||||||
### Object Recording
|
### Object Recording
|
||||||
|
|
||||||
The number of days to record review items can be specified for review items classified as alerts as well as tracked objects.
|
The number of days to record review items can be specified for review items classified as alerts as well as tracked objects.
|
||||||
@ -116,7 +139,9 @@ This configuration will retain recording segments that overlap with alerts and d
|
|||||||
|
|
||||||
## Can I have "continuous" recordings, but only at certain times?
|
## Can I have "continuous" recordings, but only at certain times?
|
||||||
|
|
||||||
Using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times.
|
Yes! You can use time-window based retention to keep recordings only from specific hours. See [Time-Window Based Retention](#time-window-based-retention) above.
|
||||||
|
|
||||||
|
Alternatively, using Frigate UI, Home Assistant, or MQTT, cameras can be automated to only record in certain situations or at certain times.
|
||||||
|
|
||||||
## How do I export recordings?
|
## How do I export recordings?
|
||||||
|
|
||||||
|
|||||||
@ -517,13 +517,31 @@ record:
|
|||||||
# Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below)
|
# Optional: Number of days to retain recordings regardless of tracked objects or motion (default: shown below)
|
||||||
# NOTE: This should be set to 0 and retention should be defined in alerts and detections section below
|
# NOTE: This should be set to 0 and retention should be defined in alerts and detections section below
|
||||||
# if you only want to retain recordings of alerts and detections.
|
# if you only want to retain recordings of alerts and detections.
|
||||||
|
# Set to null for unlimited retention.
|
||||||
days: 0
|
days: 0
|
||||||
|
# Optional: Time windows to retain recordings (default: not set, keep all times)
|
||||||
|
# After always_retain period, only recordings within these windows are kept.
|
||||||
|
# Format: "HH:MM-HH:MM" or "mon-fri HH:MM-HH:MM" or "sat HH:MM-HH:MM"
|
||||||
|
hours:
|
||||||
|
- "mon-fri 07:00-23:00"
|
||||||
|
- "sat-sun 10:00-18:00"
|
||||||
|
# Optional: Hours to retain all recordings before applying time window filter (default: shown below)
|
||||||
|
always_retain: 24
|
||||||
# Optional: Motion retention settings
|
# Optional: Motion retention settings
|
||||||
motion:
|
motion:
|
||||||
# Optional: Number of days to retain recordings regardless of tracked objects (default: shown below)
|
# Optional: Number of days to retain recordings regardless of tracked objects (default: shown below)
|
||||||
# NOTE: This should be set to 0 and retention should be defined in alerts and detections section below
|
# NOTE: This should be set to 0 and retention should be defined in alerts and detections section below
|
||||||
# if you only want to retain recordings of alerts and detections.
|
# if you only want to retain recordings of alerts and detections.
|
||||||
|
# Set to null for unlimited retention.
|
||||||
days: 0
|
days: 0
|
||||||
|
# Optional: Time windows to retain recordings (default: not set, keep all times)
|
||||||
|
# After always_retain period, only recordings within these windows are kept.
|
||||||
|
# Format: "HH:MM-HH:MM" or "mon-fri HH:MM-HH:MM" or "sat HH:MM-HH:MM"
|
||||||
|
hours:
|
||||||
|
- "mon-fri 07:00-23:00"
|
||||||
|
- "sat-sun 10:00-18:00"
|
||||||
|
# Optional: Hours to retain all recordings before applying time window filter (default: shown below)
|
||||||
|
always_retain: 24
|
||||||
# Optional: Recording Export Settings
|
# Optional: Recording Export Settings
|
||||||
export:
|
export:
|
||||||
# Optional: Timelapse Output Args (default: shown below).
|
# Optional: Timelapse Output Args (default: shown below).
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
import re
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from frigate.const import MAX_PRE_CAPTURE
|
from frigate.const import MAX_PRE_CAPTURE
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
@ -21,9 +22,117 @@ __all__ = [
|
|||||||
|
|
||||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||||
|
|
||||||
|
WEEKDAY_MAP = {
|
||||||
|
"mon": 0,
|
||||||
|
"monday": 0,
|
||||||
|
"tue": 1,
|
||||||
|
"tuesday": 1,
|
||||||
|
"wed": 2,
|
||||||
|
"wednesday": 2,
|
||||||
|
"thu": 3,
|
||||||
|
"thursday": 3,
|
||||||
|
"fri": 4,
|
||||||
|
"friday": 4,
|
||||||
|
"sat": 5,
|
||||||
|
"saturday": 5,
|
||||||
|
"sun": 6,
|
||||||
|
"sunday": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
TIME_WINDOW_PATTERN = re.compile(
|
||||||
|
r"^(?:([a-z]+)(?:-([a-z]+))?\s+)?(\d{2}):(\d{2})-(\d{2}):(\d{2})$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time_window(entry: str) -> dict:
|
||||||
|
"""Parse time window string into components."""
|
||||||
|
match = TIME_WINDOW_PATTERN.match(entry.strip())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid time window format: '{entry}'. "
|
||||||
|
"Use 'HH:MM-HH:MM' or 'mon-fri HH:MM-HH:MM'"
|
||||||
|
)
|
||||||
|
|
||||||
|
day_start, day_end, start_h, start_m, end_h, end_m = match.groups()
|
||||||
|
|
||||||
|
weekdays = None
|
||||||
|
if day_start:
|
||||||
|
start_day = day_start.lower()
|
||||||
|
if start_day not in WEEKDAY_MAP:
|
||||||
|
raise ValueError(f"Invalid weekday: '{day_start}'")
|
||||||
|
start_num = WEEKDAY_MAP[start_day]
|
||||||
|
|
||||||
|
if day_end:
|
||||||
|
end_day = day_end.lower()
|
||||||
|
if end_day not in WEEKDAY_MAP:
|
||||||
|
raise ValueError(f"Invalid weekday: '{day_end}'")
|
||||||
|
end_num = WEEKDAY_MAP[end_day]
|
||||||
|
if start_num <= end_num:
|
||||||
|
weekdays = list(range(start_num, end_num + 1))
|
||||||
|
else:
|
||||||
|
weekdays = list(range(start_num, 7)) + list(range(0, end_num + 1))
|
||||||
|
else:
|
||||||
|
weekdays = [start_num]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"weekdays": weekdays,
|
||||||
|
"start": int(start_h) * 60 + int(start_m),
|
||||||
|
"end": int(end_h) * 60 + int(end_m),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_time_window(
|
||||||
|
windows: list[dict], weekday: int, hour: int, minute: int
|
||||||
|
) -> bool:
|
||||||
|
"""Check if given time falls within any time window."""
|
||||||
|
time_val = hour * 60 + minute
|
||||||
|
for window in windows:
|
||||||
|
if window["weekdays"] is not None and weekday not in window["weekdays"]:
|
||||||
|
continue
|
||||||
|
start, end = window["start"], window["end"]
|
||||||
|
if start <= end:
|
||||||
|
if start <= time_val <= end:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# overnight range like 22:00-06:00
|
||||||
|
if time_val >= start or time_val <= end:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class RecordRetainConfig(FrigateBaseModel):
|
class RecordRetainConfig(FrigateBaseModel):
|
||||||
days: float = Field(default=0, ge=0, title="Default retention period.")
|
days: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
ge=0,
|
||||||
|
title="Maximum retention period in days. None for unlimited, 0 for no retention.",
|
||||||
|
)
|
||||||
|
hours: Optional[list[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
title="Time windows to retain recordings. Outside these windows, recordings are deleted after always_retain period.",
|
||||||
|
)
|
||||||
|
always_retain: float = Field(
|
||||||
|
default=24,
|
||||||
|
ge=0,
|
||||||
|
title="Hours to keep all recordings before applying time window filter.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("hours", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_hours(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("hours must be a list")
|
||||||
|
for entry in v:
|
||||||
|
_parse_time_window(entry)
|
||||||
|
return v
|
||||||
|
|
||||||
|
def is_in_retention_window(self, weekday: int, hour: int, minute: int = 0) -> bool:
|
||||||
|
"""Check if recordings at this time should be retained."""
|
||||||
|
if not self.hours:
|
||||||
|
return True
|
||||||
|
windows = [_parse_time_window(s) for s in self.hours]
|
||||||
|
return _matches_time_window(windows, weekday, hour, minute)
|
||||||
|
|
||||||
|
|
||||||
class RetainModeEnum(str, Enum):
|
class RetainModeEnum(str, Enum):
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||||
|
|
||||||
@ -104,13 +105,28 @@ class RecordingCleanup(threading.Thread):
|
|||||||
self,
|
self,
|
||||||
continuous_expire_date: float,
|
continuous_expire_date: float,
|
||||||
motion_expire_date: float,
|
motion_expire_date: float,
|
||||||
|
window_expire_date: Optional[float],
|
||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
reviews: ReviewSegment,
|
reviews: ReviewSegment,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete recordings for existing camera based on retention config."""
|
"""Delete recordings for existing camera based on retention config."""
|
||||||
# Get the timestamp for cutoff of retained days
|
# Build query condition for recordings to check
|
||||||
|
expire_condition = (Recordings.camera == config.name) & (
|
||||||
|
(
|
||||||
|
(Recordings.end_time < continuous_expire_date)
|
||||||
|
& (Recordings.motion == 0)
|
||||||
|
& (Recordings.dBFS == 0)
|
||||||
|
)
|
||||||
|
| (Recordings.end_time < motion_expire_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include recordings past time-window cutoff for filtering
|
||||||
|
if window_expire_date is not None:
|
||||||
|
expire_condition = expire_condition | (
|
||||||
|
(Recordings.camera == config.name)
|
||||||
|
& (Recordings.end_time < window_expire_date)
|
||||||
|
)
|
||||||
|
|
||||||
# Get recordings to check for expiration
|
|
||||||
recordings: Recordings = (
|
recordings: Recordings = (
|
||||||
Recordings.select(
|
Recordings.select(
|
||||||
Recordings.id,
|
Recordings.id,
|
||||||
@ -121,17 +137,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
Recordings.motion,
|
Recordings.motion,
|
||||||
Recordings.dBFS,
|
Recordings.dBFS,
|
||||||
)
|
)
|
||||||
.where(
|
.where(expire_condition)
|
||||||
(Recordings.camera == config.name)
|
|
||||||
& (
|
|
||||||
(
|
|
||||||
(Recordings.end_time < continuous_expire_date)
|
|
||||||
& (Recordings.motion == 0)
|
|
||||||
& (Recordings.dBFS == 0)
|
|
||||||
)
|
|
||||||
| (Recordings.end_time < motion_expire_date)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(Recordings.start_time)
|
.order_by(Recordings.start_time)
|
||||||
.namedtuples()
|
.namedtuples()
|
||||||
.iterator()
|
.iterator()
|
||||||
@ -180,16 +186,35 @@ class RecordingCleanup(threading.Thread):
|
|||||||
if review.end_time + post_capture < recording.start_time:
|
if review.end_time + post_capture < recording.start_time:
|
||||||
review_start = idx
|
review_start = idx
|
||||||
|
|
||||||
|
# Check time-window retention for recordings without review overlap
|
||||||
|
within_window = False
|
||||||
|
motion_config = config.record.motion
|
||||||
|
if motion_config.hours and not keep:
|
||||||
|
recording_dt = datetime.datetime.fromtimestamp(recording.start_time)
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
age_hours = (now - recording_dt).total_seconds() / 3600
|
||||||
|
if age_hours <= motion_config.always_retain:
|
||||||
|
within_window = True
|
||||||
|
elif motion_config.is_in_retention_window(
|
||||||
|
recording_dt.weekday(), recording_dt.hour, recording_dt.minute
|
||||||
|
):
|
||||||
|
within_window = True
|
||||||
|
|
||||||
# Delete recordings outside of the retention window or based on the retention mode
|
# Delete recordings outside of the retention window or based on the retention mode
|
||||||
if (
|
if (
|
||||||
not keep
|
(not keep and not within_window)
|
||||||
or (
|
or (
|
||||||
mode == RetainModeEnum.motion
|
keep
|
||||||
|
and mode == RetainModeEnum.motion
|
||||||
and recording.motion == 0
|
and recording.motion == 0
|
||||||
and recording.objects == 0
|
and recording.objects == 0
|
||||||
and recording.dBFS == 0
|
and recording.dBFS == 0
|
||||||
)
|
)
|
||||||
or (mode == RetainModeEnum.active_objects and recording.objects == 0)
|
or (
|
||||||
|
keep
|
||||||
|
and mode == RetainModeEnum.active_objects
|
||||||
|
and recording.objects == 0
|
||||||
|
)
|
||||||
):
|
):
|
||||||
Path(recording.path).unlink(missing_ok=True)
|
Path(recording.path).unlink(missing_ok=True)
|
||||||
deleted_recordings.add(recording.id)
|
deleted_recordings.add(recording.id)
|
||||||
@ -271,10 +296,13 @@ class RecordingCleanup(threading.Thread):
|
|||||||
logger.debug("Start expire recordings.")
|
logger.debug("Start expire recordings.")
|
||||||
logger.debug("Start deleted cameras.")
|
logger.debug("Start deleted cameras.")
|
||||||
|
|
||||||
# Handle deleted cameras
|
# Handle deleted cameras - use configured days or skip if unlimited
|
||||||
expire_days = max(
|
continuous_days = self.config.record.continuous.days
|
||||||
self.config.record.continuous.days, self.config.record.motion.days
|
motion_days = self.config.record.motion.days
|
||||||
)
|
if continuous_days is None and motion_days is None:
|
||||||
|
expire_days = 0 # no day-based expiration configured
|
||||||
|
else:
|
||||||
|
expire_days = max(continuous_days or 0, motion_days or 0)
|
||||||
expire_before = (
|
expire_before = (
|
||||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
@ -312,18 +340,34 @@ class RecordingCleanup(threading.Thread):
|
|||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
|
|
||||||
self.expire_review_segments(config, now)
|
self.expire_review_segments(config, now)
|
||||||
|
|
||||||
|
# Calculate expire dates - None means unlimited (use far future)
|
||||||
|
continuous_days = config.record.continuous.days
|
||||||
|
motion_days = config.record.motion.days
|
||||||
continuous_expire_date = (
|
continuous_expire_date = (
|
||||||
now - datetime.timedelta(days=config.record.continuous.days)
|
now
|
||||||
|
- datetime.timedelta(
|
||||||
|
days=continuous_days if continuous_days is not None else 36500
|
||||||
|
)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
motion_expire_date = (
|
motion_expire_date = (
|
||||||
now
|
now
|
||||||
- datetime.timedelta(
|
- datetime.timedelta(
|
||||||
days=max(
|
days=max(
|
||||||
config.record.motion.days, config.record.continuous.days
|
motion_days if motion_days is not None else 36500,
|
||||||
) # can't keep motion for less than continuous
|
continuous_days if continuous_days is not None else 0,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
|
|
||||||
|
# Calculate time-window cutoff if configured
|
||||||
|
motion_config = config.record.motion
|
||||||
|
window_expire_date = None
|
||||||
|
if motion_config.hours:
|
||||||
|
window_expire_date = (
|
||||||
|
now - datetime.timedelta(hours=motion_config.always_retain)
|
||||||
|
).timestamp()
|
||||||
|
|
||||||
# Get all the reviews to check against
|
# Get all the reviews to check against
|
||||||
reviews: ReviewSegment = (
|
reviews: ReviewSegment = (
|
||||||
ReviewSegment.select(
|
ReviewSegment.select(
|
||||||
@ -342,7 +386,11 @@ class RecordingCleanup(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.expire_existing_camera_recordings(
|
self.expire_existing_camera_recordings(
|
||||||
continuous_expire_date, motion_expire_date, config, reviews
|
continuous_expire_date,
|
||||||
|
motion_expire_date,
|
||||||
|
window_expire_date,
|
||||||
|
config,
|
||||||
|
reviews,
|
||||||
)
|
)
|
||||||
logger.debug(f"End camera: {camera}.")
|
logger.debug(f"End camera: {camera}.")
|
||||||
|
|
||||||
|
|||||||
86
frigate/test/test_time_window_retention.py
Normal file
86
frigate/test/test_time_window_retention.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from frigate.config.camera.record import (
|
||||||
|
RecordRetainConfig,
|
||||||
|
_matches_time_window,
|
||||||
|
_parse_time_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeWindowParsing(unittest.TestCase):
|
||||||
|
def test_parse_time_only(self):
|
||||||
|
result = _parse_time_window("07:00-18:00")
|
||||||
|
self.assertIsNone(result["weekdays"])
|
||||||
|
self.assertEqual(result["start"], 7 * 60)
|
||||||
|
self.assertEqual(result["end"], 18 * 60)
|
||||||
|
|
||||||
|
def test_parse_single_day(self):
|
||||||
|
result = _parse_time_window("mon 09:00-17:00")
|
||||||
|
self.assertEqual(result["weekdays"], [0])
|
||||||
|
self.assertEqual(result["start"], 9 * 60)
|
||||||
|
self.assertEqual(result["end"], 17 * 60)
|
||||||
|
|
||||||
|
def test_parse_day_range(self):
|
||||||
|
result = _parse_time_window("mon-fri 07:00-23:00")
|
||||||
|
self.assertEqual(result["weekdays"], [0, 1, 2, 3, 4])
|
||||||
|
|
||||||
|
def test_parse_day_range_wrap(self):
|
||||||
|
result = _parse_time_window("fri-mon 10:00-16:00")
|
||||||
|
self.assertEqual(result["weekdays"], [4, 5, 6, 0])
|
||||||
|
|
||||||
|
def test_parse_full_day_names(self):
|
||||||
|
result = _parse_time_window("monday-friday 08:00-18:00")
|
||||||
|
self.assertEqual(result["weekdays"], [0, 1, 2, 3, 4])
|
||||||
|
|
||||||
|
def test_parse_invalid_format(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
_parse_time_window("invalid")
|
||||||
|
|
||||||
|
def test_parse_invalid_weekday(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
_parse_time_window("xyz 10:00-12:00")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeWindowMatching(unittest.TestCase):
|
||||||
|
def test_match_all_days(self):
|
||||||
|
windows = [_parse_time_window("07:00-23:00")]
|
||||||
|
self.assertTrue(_matches_time_window(windows, 0, 12, 0))
|
||||||
|
self.assertTrue(_matches_time_window(windows, 6, 12, 0))
|
||||||
|
self.assertFalse(_matches_time_window(windows, 0, 3, 0))
|
||||||
|
|
||||||
|
def test_match_weekday_range(self):
|
||||||
|
windows = [_parse_time_window("mon-fri 09:00-17:00")]
|
||||||
|
self.assertTrue(_matches_time_window(windows, 2, 12, 0))
|
||||||
|
self.assertFalse(_matches_time_window(windows, 5, 12, 0))
|
||||||
|
self.assertFalse(_matches_time_window(windows, 2, 8, 0))
|
||||||
|
|
||||||
|
def test_match_overnight(self):
|
||||||
|
windows = [_parse_time_window("22:00-06:00")]
|
||||||
|
self.assertTrue(_matches_time_window(windows, 0, 23, 0))
|
||||||
|
self.assertTrue(_matches_time_window(windows, 0, 3, 0))
|
||||||
|
self.assertFalse(_matches_time_window(windows, 0, 12, 0))
|
||||||
|
|
||||||
|
def test_match_multiple_windows(self):
|
||||||
|
windows = [
|
||||||
|
_parse_time_window("mon-fri 07:00-18:00"),
|
||||||
|
_parse_time_window("sat-sun 10:00-16:00"),
|
||||||
|
]
|
||||||
|
self.assertTrue(_matches_time_window(windows, 0, 12, 0))
|
||||||
|
self.assertTrue(_matches_time_window(windows, 5, 12, 0))
|
||||||
|
self.assertFalse(_matches_time_window(windows, 5, 8, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordRetainConfig(unittest.TestCase):
|
||||||
|
def test_no_hours_always_retained(self):
|
||||||
|
config = RecordRetainConfig()
|
||||||
|
self.assertTrue(config.is_in_retention_window(0, 3, 0))
|
||||||
|
|
||||||
|
def test_hours_filters_correctly(self):
|
||||||
|
config = RecordRetainConfig(hours=["mon-fri 09:00-17:00"])
|
||||||
|
self.assertTrue(config.is_in_retention_window(0, 12, 0))
|
||||||
|
self.assertFalse(config.is_in_retention_window(0, 20, 0))
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
config = RecordRetainConfig()
|
||||||
|
self.assertIsNone(config.days)
|
||||||
|
self.assertEqual(config.always_retain, 24)
|
||||||
Loading…
Reference in New Issue
Block a user