Skip to content
Open
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
37 changes: 35 additions & 2 deletions backend/apps/api/rest/v0/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from apps.api.decorators.cache import cache_response
from apps.api.rest.v0.common import Leader, ValidationErrorSchema
from apps.api.rest.v0.structured_search import FieldConfig, apply_structured_search
from apps.owasp.models.enums.project import ProjectLevel
from apps.owasp.models.enums.project import ProjectLevel, ProjectType
from apps.owasp.models.project import Project as ProjectModel

PROJECT_SEARCH_FIELDS: dict[str, FieldConfig] = {
Expand All @@ -25,6 +25,14 @@
"type": "number",
"field": "stars_count",
},
"contributors": {
"type": "number",
"field": "contributors_count",
},
"forks": {
"type": "number",
"field": "forks_count",
},
}

router = RouterPaginated(tags=["Projects"])
Expand Down Expand Up @@ -77,6 +85,10 @@ class ProjectFilter(FilterSchema):
None,
description="Level of the project",
)
type: ProjectType | None = Field(
None,
description="Type (category) of the project",
)
q: str | None = Field(
None,
description="Structured search query (e.g. 'name:security stars:>100')",
Expand All @@ -94,7 +106,25 @@ 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_raw",
"-level_raw",
"level",
Comment thread
anurag2787 marked this conversation as resolved.
"-level",
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| None = Query(
None,
description="Ordering field",
),
Expand All @@ -109,6 +139,9 @@ def list_projects(
if filters.level is not None:
queryset = queryset.filter(level=filters.level)

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

return queryset.order_by(ordering or "-level_raw", "-stars_count", "-forks_count")


Expand Down
17 changes: 17 additions & 0 deletions backend/apps/owasp/api/internal/filters/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Filters for OWASP Project."""

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:
"""Filter for Project."""

@strawberry_django.filter_field
def type(self, value: ProjectType, prefix: str):
"""Filter by project type (category)."""
return Q(type=ProjectType(value)) if value else Q()
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 @@
"""OWASP Project Ordering."""

import strawberry
from strawberry_django import order_type

from apps.owasp.models.project import Project


@order_type(Project)
class ProjectOrder:
"""Ordering for Project."""

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
131 changes: 119 additions & 12 deletions backend/apps/owasp/api/internal/queries/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@
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 +56,122 @@ 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 projects with filtering, ordering, and pagination.

Args:
filters (ProjectFilter, optional): Filters to apply on projects.
ordering (list[ProjectOrder], optional): Ordering parameters.
pagination (OffsetPaginationInput, optional): Pagination parameters.

Returns:
list[ProjectNode]: List of projects.

"""
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:
if pagination.limit <= 0:
return []
pagination.limit = min(pagination.limit, MAX_PROJECTS_LIMIT)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return queryset

@strawberry_django.field(
filters=ProjectFilter,
ordering=ProjectOrder,
pagination=True,
)
def search_projects(
Comment thread
anurag2787 marked this conversation as resolved.
self,
query: str = "",
filters: ProjectFilter | None = None,
ordering: list[ProjectOrder] | None = None,
pagination: OffsetPaginationInput | None = None,
) -> list[ProjectNode]:
"""Search active projects by name with filtering, ordering, and pagination.

Args:
query (str): The search query string (can be empty to show all projects).
filters (ProjectFilter, optional): Filters to apply on search results.
ordering (list[ProjectOrder], optional): Ordering parameters.
pagination (OffsetPaginationInput, optional): Pagination parameters.

Returns:
list[ProjectNode]: List of projects matching the search query.

"""
cleaned_query = query.strip()
if (
len(cleaned_query) < MIN_SEARCH_QUERY_LENGTH
or 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)
Comment thread
anurag2787 marked this conversation as resolved.

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:
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:
"""Get count of active projects matching the search query and filters.

Excludes pagination to return the total matching count.

Args:
query (str): The search query string (can be empty to get all projects count).
filters (ProjectFilter, optional): Filters to apply on the count.

Returns:
int: Count of projects matching the search query and filters.

"""
cleaned_query = query.strip()

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 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 search query, filters, pagination, and ordering.

Args:
query (str): The search query string for 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,16 @@ 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)

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

return queryset

@strawberry_django.field(
permission_classes=[HasDashboardAccess],
Expand All @@ -72,11 +83,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 (str): The search query string for project name.
filters (ProjectHealthMetricsFilter | None): Filters to apply on the metrics.

Returns:
Expand All @@ -85,6 +98,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
5 changes: 5 additions & 0 deletions backend/apps/owasp/index/search/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def get_projects(
limit: int = 25,
page: int = 1,
searchable_attributes: list | None = None,
filters: str | None = None,
) -> dict:
"""Return projects relevant to a search query.

Expand All @@ -23,6 +24,7 @@ def get_projects(
limit (int, optional): Number of results per page.
page (int, optional): Page number for pagination.
searchable_attributes (list, optional): Attributes to restrict the search to.
filters (str, optional): Filter expression for faceted search (e.g., "idx_type:code").

Returns:
dict: Search results containing projects matching the query.
Expand Down Expand Up @@ -54,4 +56,7 @@ def get_projects(
if searchable_attributes:
params["restrictSearchableAttributes"] = searchable_attributes

if filters:
params["filters"] = filters

return raw_search(Project, query, params)
1 change: 1 addition & 0 deletions frontend/.env.e2e.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
NEXTAUTH_SECRET=<your-nextauth-secret>
NEXTAUTH_URL=http://localhost:3000/
NEXT_PUBLIC_API_URL=http://localhost:9000/
NEXT_PUBLIC_SEARCH_BACKEND=algolia
NEXT_PUBLIC_CSRF_URL=http://localhost:9000/csrf/
NEXT_PUBLIC_ENVIRONMENT=local
NEXT_PUBLIC_GRAPHQL_URL=http://localhost:9000/graphql/
Expand Down
1 change: 1 addition & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
NEXTAUTH_SECRET=<your-nextauth-secret>
NEXTAUTH_URL=http://localhost:3000/
NEXT_PUBLIC_API_URL=http://localhost:8000/
NEXT_PUBLIC_SEARCH_BACKEND=algolia
NEXT_PUBLIC_CSRF_URL=http://localhost:8000/csrf/
NEXT_PUBLIC_ENVIRONMENT=local
NEXT_PUBLIC_GRAPHQL_URL=http://localhost:8000/graphql/
Expand Down
Loading