Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ repos:
rev: v0.48.0
hooks:
- id: markdownlint
language_version: 22.20.0
args:
- --fix
files: \.md$
Expand Down
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
104 changes: 91 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,19 @@
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
MAX_PROJECTS_LIMIT = 1000
MAX_OFFSET = 10000


@strawberry.type
Expand Down Expand Up @@ -51,20 +54,95 @@ 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 len(cleaned_query) > MAX_SEARCH_QUERY_LENGTH:
return []

return Project.objects.filter(
is_active=True,
name__icontains=cleaned_query,
).order_by("name")[:SEARCH_PROJECTS_LIMIT]
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