Skip to content

Commit

Permalink
feat(dynamic_filters): add dynamic filters system
Browse files Browse the repository at this point in the history
  • Loading branch information
AdriiiPRodri committed Nov 30, 2024
1 parent e09a04d commit 601a51e
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 1 deletion.
10 changes: 10 additions & 0 deletions api/src/backend/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,16 @@ def maybe_date_to_datetime(value):
return dt


class FindingDynamicFilter(FilterSet):
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")

class Meta:
model = Finding
fields = {
"updated_at": ["exact"],
}


class ProviderSecretFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
Expand Down
47 changes: 47 additions & 0 deletions api/src/backend/api/specs/v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,49 @@ paths:
schema:
$ref: '#/components/schemas/FindingResponse'
description: ''
/api/v1/findings/findings_services_regions:
get:
operationId: findings_findings_services_regions_retrieve
description: Fetch services and regions affected in a finding by date.
summary: Retrieve the services and regions that are impacted by a finding in
a specific date
parameters:
- in: query
name: fields[findings-services-regions]
schema:
type: array
items:
type: string
enum:
- services
- regions
description: endpoint return only specific fields in the response on a per-type
basis by including a fields[TYPE] query parameter.
explode: false
- name: filter[search]
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: filter[updated_at]
schema:
type: string
format: date
description: Date in the format YYYY-MM-DD
required: true
tags:
- Finding
security:
- jwtAuth: []
responses:
'201':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/OpenApiResponseResponse'
description: ''
/api/v1/invitations/accept:
post:
operationId: invitations_accept_create
Expand Down Expand Up @@ -4620,6 +4663,7 @@ components:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
Expand All @@ -4628,6 +4672,9 @@ components:
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
id:
type: string
format: uuid
attributes:
type: object
properties:
Expand Down
50 changes: 50 additions & 0 deletions api/src/backend/api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2450,6 +2450,56 @@ def test_findings_invalid_retrieve(self, authenticated_client):
)
assert response.status_code == status.HTTP_404_NOT_FOUND

def test_findings_services_regions_retrieve(
self, authenticated_client, findings_fixture
):
finding_1, *_ = findings_fixture
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[updated_at]": finding_1.updated_at.strftime("%Y-%m-%d")},
)
data = response.json()

expected_services = {"ec2", "s3"}
expected_regions = {"us-east-1", "eu-west-1"}

assert data["data"]["type"] == "findings-services-regions"
assert data["data"]["id"] is None
assert set(data["data"]["attributes"]["services"]) == expected_services
assert set(data["data"]["attributes"]["regions"]) == expected_regions

def test_findings_services_regions_future_date(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[updated_at]": "2048-01-01"},
)
assert response.json() == {
"errors": [
{
"detail": "The date must be a date in the past.",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
}

def test_findings_services_regions_invalid_date(self, authenticated_client):
response = authenticated_client.get(
reverse("finding-findings_services_regions"),
{"filter[updated_at]": "2048-01-011"},
)
assert response.json() == {
"errors": [
{
"detail": "Invalid date format.",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
}


@pytest.mark.django_db
class TestJWTFields:
Expand Down
8 changes: 8 additions & 0 deletions api/src/backend/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,14 @@ class Meta:
}


class FindingDynamicFilterSerializer(serializers.Serializer):
services = serializers.ListField(child=serializers.CharField())
regions = serializers.ListField(child=serializers.CharField())

class Meta:
resource_name = "findings-services-regions"


# Provider secrets
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@staticmethod
Expand Down
65 changes: 64 additions & 1 deletion api/src/backend/api/v1/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from celery.result import AsyncResult
from datetime import date, datetime
from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.search import SearchQuery
from django.db import transaction
from django.db.models import Count, F, OuterRef, Prefetch, Q, Subquery, Sum
Expand Down Expand Up @@ -39,6 +41,7 @@
from api.filters import (
ComplianceOverviewFilter,
FindingFilter,
FindingDynamicFilter,
InvitationFilter,
MembershipFilter,
ProviderFilter,
Expand Down Expand Up @@ -76,6 +79,7 @@
ComplianceOverviewFullSerializer,
ComplianceOverviewSerializer,
FindingSerializer,
FindingDynamicFilterSerializer,
InvitationAcceptSerializer,
InvitationCreateSerializer,
InvitationSerializer,
Expand Down Expand Up @@ -978,6 +982,22 @@ def get_queryset(self):
summary="Retrieve data from a specific finding",
description="Fetch detailed information about a specific finding by its ID.",
),
findings_services_regions=extend_schema(
tags=["Finding"],
summary="Retrieve the services and regions that are impacted by a finding in a specific date",
description="Fetch services and regions affected in a finding by date.",
parameters=[
OpenApiParameter(
name="filter[updated_at]",
required=True,
type=OpenApiTypes.DATE,
location=OpenApiParameter.QUERY,
description="Date in the format YYYY-MM-DD",
),
],
responses={201: OpenApiResponse(response=MembershipSerializer)},
filters=True,
),
)
@method_decorator(CACHE_DECORATOR, name="list")
@method_decorator(CACHE_DECORATOR, name="retrieve")
Expand All @@ -992,7 +1012,6 @@ class FindingViewSet(BaseRLSViewSet):
"scan": [Prefetch("scan", queryset=Scan.objects.select_related("findings"))],
}
http_method_names = ["get"]
filterset_class = FindingFilter
ordering = ["-id"]
ordering_fields = [
"id",
Expand All @@ -1008,6 +1027,24 @@ def inserted_at_to_uuidv7(self, inserted_at):
return None
return datetime_to_uuid7(inserted_at)

def get_serializer_class(self):
if self.action == "findings_services_regions":
return FindingDynamicFilterSerializer

return super().get_serializer_class()

def get_filterset_class(self):
if self.action in ["findings_services_regions"]:
self.ordering_fields = []
return FindingDynamicFilter

return FindingFilter

def get_ordering_fields(self):
if self.action == "findings_services_regions":
return None
return self.ordering_fields

Check warning on line 1046 in api/src/backend/api/v1/views.py

View check run for this annotation

Codecov / codecov/patch

api/src/backend/api/v1/views.py#L1044-L1046

Added lines #L1044 - L1046 were not covered by tests

def get_queryset(self):
queryset = Finding.objects.all()
search_value = self.request.query_params.get("filter[search]", None)
Expand Down Expand Up @@ -1039,6 +1076,32 @@ def get_queryset(self):

return queryset

@action(detail=False, methods=["get"], url_name="findings_services_regions")
def findings_services_regions(self, request):
try:
updated_at = datetime.strptime(
request.query_params["filter[updated_at]"], "%Y-%m-%d"
).date()
if updated_at > date.today():
raise ValidationError(detail="The date must be a date in the past.")
except ValueError:
raise ValidationError(detail="Invalid date format.")

queryset = self.get_queryset()
filtered_queryset = self.filter_queryset(queryset)

result = filtered_queryset.aggregate(
services=ArrayAgg("resources__service", flat=True, distinct=True),
regions=ArrayAgg("resources__region", flat=True, distinct=True),
)

serializer = self.get_serializer(
data=result,
)
serializer.is_valid(raise_exception=True)

return Response(data=serializer.data, status=status.HTTP_200_OK)


@extend_schema_view(
list=extend_schema(
Expand Down

0 comments on commit 601a51e

Please sign in to comment.