Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@ -78,6 +78,7 @@ repos:
args:
- --fix
files: \.md$
language_version: 22.13.0

- repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt
rev: 0.2.3
Expand Down
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
129 changes: 117 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,120 @@ 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 and len(cleaned_query) >= MIN_SEARCH_QUERY_LENGTH:
base_queryset = base_queryset.filter(name__icontains=cleaned_query)
Comment thread
anurag2787 marked this conversation as resolved.
Outdated

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 and len(cleaned_query) >= MIN_SEARCH_QUERY_LENGTH:
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 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