|
1 | 1 | from datetime import date, datetime, timedelta, timezone |
| 2 | +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError |
2 | 3 |
|
3 | 4 | from dateutil.parser import parse |
4 | 5 | from django.conf import settings |
5 | | -from django.db.models import F, Q |
| 6 | +from django.db.models import F, Q, TextChoices |
| 7 | +from django.db.models.functions import TruncDay, TruncHour, TruncWeek |
6 | 8 | from django_filters.rest_framework import ( |
7 | 9 | BaseInFilter, |
8 | 10 | BooleanFilter, |
@@ -802,6 +804,89 @@ class Meta: |
802 | 804 | } |
803 | 805 |
|
804 | 806 |
|
| 807 | +class ScanSummaryTimeSeriesFilter(ScanSummaryFilter): |
| 808 | + """ |
| 809 | + Filter for findings_severity_timeseries endpoint. |
| 810 | + Handles range-based filtering on inserted_at. |
| 811 | + """ |
| 812 | + |
| 813 | + class TimeRangeChoices(TextChoices): |
| 814 | + ONE_DAY = "1D", "Last 24 Hours" |
| 815 | + FIVE_DAYS = "5D", "Last 5 Days" |
| 816 | + ONE_WEEK = "1W", "Last 7 Days" |
| 817 | + ONE_MONTH = "1M", "Last 30 Days" |
| 818 | + ALL = "All", "All Time" |
| 819 | + |
| 820 | + TIME_RANGE_CONFIG = { |
| 821 | + TimeRangeChoices.ONE_DAY: { |
| 822 | + "delta": timedelta(days=1), |
| 823 | + "trunc": TruncHour, |
| 824 | + "granularity": "hour", |
| 825 | + }, |
| 826 | + TimeRangeChoices.FIVE_DAYS: { |
| 827 | + "delta": timedelta(days=5), |
| 828 | + "trunc": TruncDay, |
| 829 | + "granularity": "day", |
| 830 | + }, |
| 831 | + TimeRangeChoices.ONE_WEEK: { |
| 832 | + "delta": timedelta(weeks=1), |
| 833 | + "trunc": TruncDay, |
| 834 | + "granularity": "day", |
| 835 | + }, |
| 836 | + TimeRangeChoices.ONE_MONTH: { |
| 837 | + "delta": timedelta(days=30), |
| 838 | + "trunc": TruncDay, |
| 839 | + "granularity": "day", |
| 840 | + }, |
| 841 | + TimeRangeChoices.ALL: { |
| 842 | + "delta": None, |
| 843 | + "trunc": TruncWeek, |
| 844 | + "granularity": "week", |
| 845 | + }, |
| 846 | + } |
| 847 | + |
| 848 | + range = ChoiceFilter( |
| 849 | + choices=TimeRangeChoices.choices, |
| 850 | + method="filter_range", |
| 851 | + help_text="Time range for the series (e.g., `1D`). Defaults to `5D`.", |
| 852 | + ) |
| 853 | + timezone = CharFilter( |
| 854 | + method="filter_noop", |
| 855 | + help_text="Timezone for aggregation following the IANA timezone database format (e.g., `America/New_York`, `Europe/London`) defaults to `UTC`.", |
| 856 | + ) |
| 857 | + |
| 858 | + @property |
| 859 | + def resolved_timezone(self): |
| 860 | + """Resolves the timezone from filter data, defaulting to UTC.""" |
| 861 | + tz_name = self.data.get("timezone", "UTC") |
| 862 | + try: |
| 863 | + return ZoneInfo(tz_name) |
| 864 | + except (ZoneInfoNotFoundError, ValueError): |
| 865 | + return timezone.utc |
| 866 | + |
| 867 | + def get_config(self, value): |
| 868 | + """Helper to get config with default fallback.""" |
| 869 | + return ( |
| 870 | + self.TIME_RANGE_CONFIG.get(value) |
| 871 | + or self.TIME_RANGE_CONFIG[self.TimeRangeChoices.FIVE_DAYS] |
| 872 | + ) |
| 873 | + |
| 874 | + def filter_range(self, queryset, name, value): |
| 875 | + """Filters the queryset by the selected time range.""" |
| 876 | + config = self.get_config(value) |
| 877 | + delta = config["delta"] |
| 878 | + |
| 879 | + if delta is not None: |
| 880 | + start_date = datetime.now(self.resolved_timezone) - delta |
| 881 | + return queryset.filter(inserted_at__gte=start_date) |
| 882 | + |
| 883 | + return queryset |
| 884 | + |
| 885 | + def filter_noop(self, queryset, name, value): |
| 886 | + """No-op filter to allow for timezone filtering.""" |
| 887 | + return queryset |
| 888 | + |
| 889 | + |
805 | 890 | class ScanSummarySeverityFilter(ScanSummaryFilter): |
806 | 891 | """Filter for findings_severity ScanSummary endpoint - includes status filters""" |
807 | 892 |
|
|
0 commit comments