Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions backend/apps/api/rest/v0/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,23 @@ class ProjectFilter(FilterSchema):
def list_projects(
request: HttpRequest,
filters: ProjectFilter = Query(...),
ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query(
ordering: Literal[
"created_at",
"-created_at",
"updated_at",
"-updated_at",
"contributors_count",
"-contributors_count",
"forks_count",
"-forks_count",
"stars_count",
"-stars_count",
"name",
"-name",
"level",
Comment thread
HarshitVerma109 marked this conversation as resolved.
"-level",
Comment thread
HarshitVerma109 marked this conversation as resolved.
]
| None = Query(
Comment thread
HarshitVerma109 marked this conversation as resolved.
None,
description="Ordering field",
),
Expand All @@ -114,10 +130,13 @@ def list_projects(
if filters.level is not None:
queryset = queryset.filter(level=filters.level)

if filters.type is not None:
if filters.type:
queryset = queryset.filter(type__in=filters.type)

return queryset.order_by(ordering or "-level_raw", "-stars_count", "-forks_count")
ordering_field = (
ordering.replace("level", "level_raw") if ordering and "level" in ordering else ordering
)
return queryset.order_by(ordering_field or "-level_raw", "-stars_count", "-forks_count", "pk")


@router.get(
Expand Down
20 changes: 20 additions & 0 deletions backend/apps/owasp/api/internal/filters/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Strawberry Django filter definitions for the Project model."""

import strawberry_django
from django.db.models import Q

from apps.owasp.models.enums.project import ProjectType
from apps.owasp.models.project import Project


@strawberry_django.filter_type(Project, lookups=True)
class ProjectFilter:
"""Strawberry filter type enabling category-based project queries."""

@strawberry_django.filter_field
def type(self, value: ProjectType, prefix: str):
"""Narrow results to a specific project category (code, tool, etc.)."""
if not value:
return Q()
lookup = f"{prefix}type" if prefix else "type"
return Q(**{lookup: value})
19 changes: 19 additions & 0 deletions backend/apps/owasp/api/internal/ordering/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Strawberry Django ordering definitions for the Project model."""

import strawberry
from strawberry_django import order_type

from apps.owasp.models.project import Project


@order_type(Project)
class ProjectOrder:
"""Sortable fields exposed to the GraphQL schema for project listings."""

contributors_count: strawberry.auto
created_at: strawberry.auto
forks_count: strawberry.auto
level: strawberry.auto
name: strawberry.auto
stars_count: strawberry.auto
updated_at: strawberry.auto
108 changes: 95 additions & 13 deletions backend/apps/owasp/api/internal/queries/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
import strawberry
import strawberry_django
from django.db.models import Q
from strawberry_django.pagination import OffsetPaginationInput

from apps.common.utils import normalize_limit
from apps.github.models.user import User as GithubUser
from apps.owasp.api.internal.filters.project import ProjectFilter
from apps.owasp.api.internal.nodes.project import ProjectNode
from apps.owasp.api.internal.ordering.project import ProjectOrder
from apps.owasp.models.project import Project

MAX_RECENT_PROJECTS_LIMIT = 1000
MAX_SEARCH_QUERY_LENGTH = 100
MIN_SEARCH_QUERY_LENGTH = 3
SEARCH_PROJECTS_LIMIT = 3
MIN_SEARCH_QUERY_LENGTH = 1
MAX_PROJECTS_LIMIT = 1000
MAX_OFFSET = 10000


@strawberry.type
Expand Down Expand Up @@ -51,20 +55,98 @@ def recent_projects(self, limit: int = 8) -> list[ProjectNode]:

return Project.objects.filter(is_active=True).order_by("-created_at")[:normalized_limit]

@strawberry_django.field
def search_projects(self, query: str) -> list[ProjectNode]:
"""Search active projects by name (case-insensitive, partial match)."""
@strawberry_django.field(
filters=ProjectFilter,
ordering=ProjectOrder,
pagination=True,
)
def projects(
self,
filters: ProjectFilter | None = None,
ordering: list[ProjectOrder] | None = None,
pagination: OffsetPaginationInput | None = None,
) -> list[ProjectNode]:
"""Resolve active projects with optional category filter, ordering, and pagination."""
queryset = Project.objects.filter(is_active=True)

if not ordering:
queryset = queryset.order_by("-stars_count", "-created_at")

if pagination:
if pagination.offset < 0:
return []
pagination.offset = min(pagination.offset, MAX_OFFSET)

if pagination.limit is not None and pagination.limit is not strawberry.UNSET:
if pagination.limit <= 0:
return []
pagination.limit = min(pagination.limit, MAX_PROJECTS_LIMIT)
Comment thread
HarshitVerma109 marked this conversation as resolved.

return queryset

@strawberry_django.field(
filters=ProjectFilter,
ordering=ProjectOrder,
pagination=True,
)
def search_projects(
self,
query: str = "",
filters: ProjectFilter | None = None,
ordering: list[ProjectOrder] | None = None,
pagination: OffsetPaginationInput | None = None,
) -> list[ProjectNode]:
"""Search active projects by name with optional filters and sorting."""
cleaned_query = query.strip()
if (
len(cleaned_query) < MIN_SEARCH_QUERY_LENGTH
or len(cleaned_query) > MAX_SEARCH_QUERY_LENGTH
):

if cleaned_query and len(cleaned_query) < MIN_SEARCH_QUERY_LENGTH:
return []
Comment thread
HarshitVerma109 marked this conversation as resolved.
Outdated

return Project.objects.filter(
is_active=True,
name__icontains=cleaned_query,
).order_by("name")[:SEARCH_PROJECTS_LIMIT]
if len(cleaned_query) > MAX_SEARCH_QUERY_LENGTH:
return []

base_queryset = Project.objects.filter(is_active=True)

if cleaned_query:
bounded_query = cleaned_query[:MAX_SEARCH_QUERY_LENGTH]
base_queryset = base_queryset.filter(name__icontains=bounded_query)

if not ordering:
base_queryset = base_queryset.order_by("-stars_count", "-created_at")

if pagination:
if pagination.offset < 0:
return []
pagination.offset = min(pagination.offset, MAX_OFFSET)

if pagination.limit is not None and pagination.limit is not strawberry.UNSET:
if pagination.limit <= 0:
return []
pagination.limit = min(pagination.limit, MAX_PROJECTS_LIMIT)

return base_queryset

@strawberry.field
def search_projects_count(
self,
query: str = "",
filters: ProjectFilter | None = None,
) -> int:
"""Return total count of matching projects for pagination."""
cleaned_query = query.strip()

if len(cleaned_query) > MAX_SEARCH_QUERY_LENGTH:
return 0

base_queryset = Project.objects.filter(is_active=True)

if cleaned_query:
base_queryset = base_queryset.filter(name__icontains=cleaned_query)

if filters:
base_queryset = strawberry_django.filters.apply(filters, base_queryset)

return base_queryset.count()

@strawberry_django.field
def is_project_leader(self, info: strawberry.Info, login: str) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ class ProjectHealthMetricsQuery:
)
def project_health_metrics(
self,
query: str = "",
filters: ProjectHealthMetricsFilter | None = None,
pagination: strawberry_django.pagination.OffsetPaginationInput | None = None,
ordering: list[ProjectHealthMetricsOrder] | None = None,
) -> list[ProjectHealthMetricsNode]:
"""Resolve project health metrics based on filters, pagination, and ordering.
"""Resolve project health metrics based on query, filters, pagination, and ordering.

Args:
query: Search string for filtering by project name.
filters (ProjectHealthMetricsFilter): Filters to apply on the metrics.
pagination (strawberry_django.pagination.OffsetPaginationInput): Pagination parameters.
ordering (list[ProjectHealthMetricsOrder], optional): Ordering parameters.
Expand All @@ -53,7 +55,13 @@ def project_health_metrics(
return []
pagination.limit = min(pagination.limit, MAX_LIMIT)

return ProjectHealthMetrics.get_latest_health_metrics()
queryset = ProjectHealthMetrics.get_latest_health_metrics()

cleaned_query = query.strip() if query else ""
if cleaned_query:
queryset = queryset.filter(project__name__icontains=cleaned_query)

return queryset

Comment thread
HarshitVerma109 marked this conversation as resolved.
@strawberry_django.field(
permission_classes=[HasDashboardAccess],
Expand All @@ -72,11 +80,13 @@ def project_health_stats(self) -> ProjectHealthStatsNode:
)
def project_health_metrics_distinct_length(
self,
query: str = "",
filters: ProjectHealthMetricsFilter | None = None,
) -> int:
"""Get the distinct length of project health metrics.

Args:
query: Search string for filtering by project name.
filters (ProjectHealthMetricsFilter | None): Filters to apply on the metrics.

Returns:
Expand All @@ -85,6 +95,10 @@ def project_health_metrics_distinct_length(
"""
queryset = ProjectHealthMetrics.get_latest_health_metrics()

cleaned_query = query.strip() if query else ""
if cleaned_query:
queryset = queryset.filter(project__name__icontains=cleaned_query)

if filters:
queryset = strawberry_django.filters.apply(filters, queryset)

Expand Down
2 changes: 2 additions & 0 deletions backend/apps/owasp/index/registry/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ class ProjectIndex(IndexBase):
"attributesForFaceting": [
"filterOnly(idx_is_active)",
"filterOnly(idx_key)",
"idx_level",
"idx_name",
"idx_tags",
"idx_type",
"idx_repositories.name",
],
"indexLanguages": ["en"],
Expand Down
20 changes: 11 additions & 9 deletions backend/tests/apps/api/rest/v0/project_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def test_list_projects_without_level_filter(
field_schema=PROJECT_SEARCH_FIELDS,
)
mock_queryset.order_by.assert_called_once_with(
"-level_raw", "-stars_count", "-forks_count"
"-level_raw", "-stars_count", "-forks_count", "pk"
)
assert result == mock_queryset

Expand Down Expand Up @@ -165,7 +165,7 @@ def test_list_projects_with_level_filter(
)
mock_queryset.filter.assert_called_once_with(level="flagship")
mock_filtered_queryset.order_by.assert_called_once_with(
"created_at", "-stars_count", "-forks_count"
"created_at", "-stars_count", "-forks_count", "pk"
)
assert result == mock_filtered_queryset

Expand Down Expand Up @@ -218,7 +218,7 @@ def test_list_projects_with_single_type_filter(

mock_queryset.filter.assert_called_once_with(type__in=["code"])
mock_filtered_queryset.order_by.assert_called_once_with(
"-level_raw", "-stars_count", "-forks_count"
"-level_raw", "-stars_count", "-forks_count", "pk"
)
assert result == mock_filtered_queryset

Expand Down Expand Up @@ -246,7 +246,7 @@ def test_list_projects_with_multiple_type_filter(

mock_queryset.filter.assert_called_once_with(type__in=["code", "tool"])
mock_filtered_queryset.order_by.assert_called_once_with(
"-level_raw", "-stars_count", "-forks_count"
"-level_raw", "-stars_count", "-forks_count", "pk"
)
assert result == mock_filtered_queryset

Expand Down Expand Up @@ -277,17 +277,19 @@ def test_list_projects_with_type_and_level_filter(
mock_queryset.filter.assert_called_once_with(level="flagship")
mock_level_filtered.filter.assert_called_once_with(type__in=["code"])
mock_type_filtered.order_by.assert_called_once_with(
"-level_raw", "-stars_count", "-forks_count"
"-level_raw", "-stars_count", "-forks_count", "pk"
)
assert result == mock_type_filtered

@pytest.mark.parametrize(
("ordering", "expected_order"),
[
("created_at", ("created_at", "-stars_count", "-forks_count")),
("-created_at", ("-created_at", "-stars_count", "-forks_count")),
("updated_at", ("updated_at", "-stars_count", "-forks_count")),
("-updated_at", ("-updated_at", "-stars_count", "-forks_count")),
("created_at", ("created_at", "-stars_count", "-forks_count", "pk")),
("-created_at", ("-created_at", "-stars_count", "-forks_count", "pk")),
("updated_at", ("updated_at", "-stars_count", "-forks_count", "pk")),
("-updated_at", ("-updated_at", "-stars_count", "-forks_count", "pk")),
("level", ("level_raw", "-stars_count", "-forks_count", "pk")),
("-level", ("-level_raw", "-stars_count", "-forks_count", "pk")),
],
)
@patch("apps.api.rest.v0.project.apply_structured_search")
Expand Down
47 changes: 47 additions & 0 deletions backend/tests/apps/owasp/api/internal/filters/project_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Test cases for the ProjectFilter."""

from django.db.models import Q

from apps.owasp.api.internal.filters.project import ProjectFilter
from apps.owasp.models.enums.project import ProjectType


class TestProjectFilter:
"""Test cases for ProjectFilter class."""

def test_filter_has_strawberry_definition(self):
"""Check if ProjectFilter has valid Strawberry definition."""
assert hasattr(ProjectFilter, "__strawberry_definition__")

def test_filter_fields(self):
"""Test if the filter fields are correctly defined."""
filter_fields = {field.name for field in ProjectFilter.__strawberry_definition__.fields}
assert "type" in filter_fields

def test_type_filter_with_valid_value(self):
"""Test type filter returns Q object with condition when value is provided."""
type_filter_method = ProjectFilter.__dict__["type"]
result = type_filter_method(None, value=ProjectType.CODE, prefix="")
assert isinstance(result, Q)
assert result == Q(type=ProjectType.CODE)

def test_type_filter_with_prefix(self):
"""Test type filter uses prefix for nested lookups."""
type_filter_method = ProjectFilter.__dict__["type"]
result = type_filter_method(None, value=ProjectType.CODE, prefix="project__")
assert isinstance(result, Q)
assert result == Q(project__type=ProjectType.CODE)

def test_type_filter_with_none_value(self):
"""Test type filter returns empty Q() when value is None."""
type_filter_method = ProjectFilter.__dict__["type"]
result = type_filter_method(None, value=None, prefix="")
assert isinstance(result, Q)
assert result == Q()

def test_type_filter_with_different_types(self):
"""Test type filter works with various ProjectType values."""
type_filter_method = ProjectFilter.__dict__["type"]
for project_type in [ProjectType.CODE, ProjectType.DOCUMENTATION, ProjectType.TOOL]:
result = type_filter_method(None, value=project_type, prefix="")
assert result == Q(type=project_type)
Loading