diff --git a/backend/apps/common/utils.py b/backend/apps/common/utils.py index 7e434beb97..4c2666b340 100644 --- a/backend/apps/common/utils.py +++ b/backend/apps/common/utils.py @@ -215,6 +215,28 @@ def truncate(text: str, limit: int, truncate: str = "...") -> str: return Truncator(text).chars(limit, truncate=truncate) +def normalize_limit(limit: int, max_limit: int = 1000) -> int | None: + """Normalize and validate a limit parameter. + + Args: + limit (int): The requested limit. + max_limit (int): The maximum allowed limit. Defaults to 1000. + + Returns: + int | None: The normalized limit capped at max_limit, or None if invalid. + + """ + try: + limit = int(limit) + except (TypeError, ValueError): + return None + + if limit <= 0: + return None + + return min(limit, max_limit) + + def validate_url(url: str | None) -> bool: """Validate that a URL has proper scheme and netloc. diff --git a/backend/apps/github/api/internal/nodes/repository.py b/backend/apps/github/api/internal/nodes/repository.py index 6a8c687d01..189bb3b514 100644 --- a/backend/apps/github/api/internal/nodes/repository.py +++ b/backend/apps/github/api/internal/nodes/repository.py @@ -5,6 +5,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.issue import IssueNode from apps.github.api.internal.nodes.milestone import MilestoneNode from apps.github.api.internal.nodes.organization import OrganizationNode @@ -15,6 +16,7 @@ if TYPE_CHECKING: from apps.owasp.api.internal.nodes.project import ProjectNode +MAX_LIMIT = 1000 RECENT_ISSUES_LIMIT = 5 RECENT_RELEASES_LIMIT = 5 @@ -69,7 +71,10 @@ def project( @strawberry_django.field(prefetch_related=["milestones"]) def recent_milestones(self, root: Repository, limit: int = 5) -> list[MilestoneNode]: """Resolve recent milestones.""" - return root.recent_milestones.order_by("-created_at")[:limit] + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return root.recent_milestones.order_by("-created_at")[:normalized_limit] @strawberry_django.field(prefetch_related=["releases"]) def releases(self, root: Repository) -> list[ReleaseNode]: diff --git a/backend/apps/github/api/internal/queries/issue.py b/backend/apps/github/api/internal/queries/issue.py index c1c077c2b3..2eef9a38ab 100644 --- a/backend/apps/github/api/internal/queries/issue.py +++ b/backend/apps/github/api/internal/queries/issue.py @@ -5,6 +5,7 @@ from django.db.models import F, Window from django.db.models.functions import Rank +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.issue import IssueNode from apps.github.models.issue import Issue @@ -61,4 +62,7 @@ def recent_issues( .order_by("-created_at") ) - return queryset[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return queryset[:normalized_limit] diff --git a/backend/apps/github/api/internal/queries/milestone.py b/backend/apps/github/api/internal/queries/milestone.py index bb87fab3a4..6128be8c2e 100644 --- a/backend/apps/github/api/internal/queries/milestone.py +++ b/backend/apps/github/api/internal/queries/milestone.py @@ -6,6 +6,7 @@ import strawberry_django from django.db.models import OuterRef, Subquery +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.milestone import MilestoneNode from apps.github.models.generic_issue_model import GenericIssueModel from apps.github.models.milestone import Milestone @@ -76,8 +77,7 @@ def recent_milestones( id__in=Subquery(latest_milestone_per_author), ) - return ( - milestones.order_by("-created_at")[:limit] - if (limit := min(limit, MAX_LIMIT)) > 0 - else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return milestones.order_by("-created_at")[:normalized_limit] diff --git a/backend/apps/github/api/internal/queries/pull_request.py b/backend/apps/github/api/internal/queries/pull_request.py index d001331003..0fe7ff8e82 100644 --- a/backend/apps/github/api/internal/queries/pull_request.py +++ b/backend/apps/github/api/internal/queries/pull_request.py @@ -5,6 +5,7 @@ from django.db.models import F, Window from django.db.models.functions import Rank +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.pull_request import PullRequestNode from apps.github.models.pull_request import PullRequest from apps.owasp.models.project import Project @@ -82,4 +83,7 @@ def recent_pull_requests( .order_by("-created_at") ) - return queryset[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return queryset[:normalized_limit] diff --git a/backend/apps/github/api/internal/queries/release.py b/backend/apps/github/api/internal/queries/release.py index 928d78a9a8..ddd4a6cb4e 100644 --- a/backend/apps/github/api/internal/queries/release.py +++ b/backend/apps/github/api/internal/queries/release.py @@ -5,6 +5,7 @@ from django.db.models import F, Window from django.db.models.functions import Rank +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.release import ReleaseNode from apps.github.models.release import Release @@ -65,4 +66,7 @@ def recent_releases( .order_by("-published_at") ) - return queryset[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return queryset[:normalized_limit] diff --git a/backend/apps/github/api/internal/queries/repository.py b/backend/apps/github/api/internal/queries/repository.py index 70a0a033b8..8efd5b4c2e 100644 --- a/backend/apps/github/api/internal/queries/repository.py +++ b/backend/apps/github/api/internal/queries/repository.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.repository import RepositoryNode from apps.github.models.repository import Repository @@ -54,12 +55,9 @@ def repositories( list[RepositoryNode]: A list of repositories. """ - return ( - ( - Repository.objects.filter( - organization__login__iexact=organization, - ).order_by("-stars_count")[:limit] - ) - if (limit := min(limit, MAX_LIMIT)) > 0 - else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return Repository.objects.filter( + organization__login__iexact=organization, + ).order_by("-stars_count")[:normalized_limit] diff --git a/backend/apps/github/api/internal/queries/repository_contributor.py b/backend/apps/github/api/internal/queries/repository_contributor.py index 9ae9c8f0b5..40103d19cc 100644 --- a/backend/apps/github/api/internal/queries/repository_contributor.py +++ b/backend/apps/github/api/internal/queries/repository_contributor.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.repository_contributor import RepositoryContributorNode from apps.github.models.repository_contributor import RepositoryContributor @@ -42,7 +43,7 @@ def top_contributors( list: List of top contributors with their details. """ - if (limit := min(limit, MAX_LIMIT)) <= 0: + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: return [] top_contributors = RepositoryContributor.get_top_contributors( @@ -50,7 +51,7 @@ def top_contributors( committee=committee, excluded_usernames=excluded_usernames, has_full_name=has_full_name, - limit=limit, + limit=normalized_limit, organization=organization, project=project, repository=repository, diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index 0a224089d3..56edf42a32 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -4,6 +4,7 @@ import strawberry +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.issue import IssueNode from apps.github.api.internal.nodes.pull_request import PullRequestNode from apps.github.api.internal.nodes.user import UserNode @@ -16,6 +17,8 @@ from apps.mentorship.models.issue_user_interest import IssueUserInterest from apps.mentorship.models.task import Task +MAX_LIMIT = 1000 + @strawberry.type class ModuleNode: @@ -79,6 +82,9 @@ def issues( self, limit: int = 20, offset: int = 0, label: str | None = None ) -> list[IssueNode]: """Return paginated issues linked to this module, optionally filtered by label.""" + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + queryset = self.issues.select_related("repository", "author").prefetch_related( "assignees", "labels" ) @@ -86,7 +92,7 @@ def issues( if label and label != "all": queryset = queryset.filter(labels__name=label) - return list(queryset.order_by("-updated_at")[offset : offset + limit]) + return list(queryset.order_by("-updated_at")[offset : offset + normalized_limit]) @strawberry.field def issues_count(self, label: str | None = None) -> int: @@ -163,12 +169,15 @@ def task_assigned_at(self, issue_number: int) -> datetime | None: @strawberry.field def recent_pull_requests(self, limit: int = 5) -> list[PullRequestNode]: """Return recent pull requests linked to issues in this module.""" + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + issue_ids = self.issues.values_list("id", flat=True) return list( PullRequest.objects.filter(related_issues__id__in=issue_ids) .select_related("author") .distinct() - .order_by("-created_at")[:limit] + .order_by("-created_at")[:normalized_limit] ) diff --git a/backend/apps/mentorship/api/internal/queries/mentorship.py b/backend/apps/mentorship/api/internal/queries/mentorship.py index a0ae2df5eb..970b8b6622 100644 --- a/backend/apps/mentorship/api/internal/queries/mentorship.py +++ b/backend/apps/mentorship/api/internal/queries/mentorship.py @@ -8,6 +8,7 @@ import strawberry from django.db.models import Prefetch +from apps.common.utils import normalize_limit from apps.github.api.internal.nodes.issue import IssueNode from apps.github.models import Label from apps.github.models.user import User as GithubUser @@ -21,6 +22,7 @@ from apps.github.api.internal.nodes.issue import IssueNode logger = logging.getLogger(__name__) +MAX_LIMIT = 1000 @strawberry.type @@ -97,6 +99,9 @@ def get_mentee_module_issues( offset: int = 0, ) -> list[IssueNode]: """Get issues assigned to a mentee in a specific module.""" + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + try: module = Module.objects.only("id").get(key=module_key, program__key=program_key) @@ -122,7 +127,7 @@ def get_mentee_module_issues( ) .order_by("-created_at") ) - issues = issues_qs[offset : offset + limit] + issues = issues_qs[offset : offset + normalized_limit] return list(issues) diff --git a/backend/apps/mentorship/api/internal/queries/program.py b/backend/apps/mentorship/api/internal/queries/program.py index fb272e8165..a785a08f36 100644 --- a/backend/apps/mentorship/api/internal/queries/program.py +++ b/backend/apps/mentorship/api/internal/queries/program.py @@ -5,12 +5,14 @@ import strawberry from django.db.models import Q +from apps.common.utils import normalize_limit from apps.mentorship.api.internal.nodes.program import PaginatedPrograms, ProgramNode from apps.mentorship.models import Program from apps.mentorship.models.mentor import Mentor from apps.nest.api.internal.permissions import IsAuthenticated PAGE_SIZE = 25 +MAX_LIMIT = 1000 logger = logging.getLogger(__name__) @@ -47,6 +49,9 @@ def my_programs( logger.warning("Mentor for user '%s' not found.", user.username) return PaginatedPrograms(programs=[], total_pages=0, current_page=page) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + normalized_limit = PAGE_SIZE + queryset = ( Program.objects.prefetch_related( "admins__github_user", "modules__mentors__github_user" @@ -59,11 +64,13 @@ def my_programs( queryset = queryset.filter(name__icontains=search) total_count = queryset.count() - total_pages = max(1, (total_count + limit - 1) // limit) + total_pages = max(1, (total_count + normalized_limit - 1) // normalized_limit) page = max(1, min(page, total_pages)) - offset = (page - 1) * limit + offset = (page - 1) * normalized_limit - paginated_programs = queryset.order_by("-nest_created_at")[offset : offset + limit] + paginated_programs = queryset.order_by("-nest_created_at")[ + offset : offset + normalized_limit + ] results = [] mentor_id = mentor.id diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py index f5da84653f..b6f2335c53 100644 --- a/backend/apps/owasp/api/internal/nodes/project.py +++ b/backend/apps/owasp/api/internal/nodes/project.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.core.utils.index import deep_camelize from apps.github.api.internal.nodes.issue import IssueNode from apps.github.api.internal.nodes.milestone import MilestoneNode @@ -53,11 +54,10 @@ def health_metrics_list( self, root: Project, limit: int = 30 ) -> list[ProjectHealthMetricsNode]: """Resolve project health metrics.""" - return ( - root.health_metrics.order_by("nest_created_at")[:limit] - if (limit := min(limit, MAX_LIMIT)) > 0 - else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return root.health_metrics.order_by("nest_created_at")[:normalized_limit] @strawberry_django.field(prefetch_related=["health_metrics"]) def health_metrics_latest(self, root: Project) -> ProjectHealthMetricsNode | None: @@ -87,6 +87,9 @@ def recent_issues(self, root: Project) -> list[IssueNode]: @strawberry_django.field def recent_milestones(self, root: Project, limit: int = 5) -> list[MilestoneNode]: """Resolve recent milestones.""" + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + return ( Milestone.objects.filter( repository__in=root.repositories.all(), @@ -95,12 +98,8 @@ def recent_milestones(self, root: Project, limit: int = 5) -> list[MilestoneNode "repository__organization", "author__owasp_profile", ) - .prefetch_related( - "labels", - ) - .order_by("-created_at")[:limit] - if (limit := min(limit, MAX_LIMIT)) > 0 - else [] + .prefetch_related("labels") + .order_by("-created_at")[:normalized_limit] ) @strawberry_django.field diff --git a/backend/apps/owasp/api/internal/queries/board_of_directors.py b/backend/apps/owasp/api/internal/queries/board_of_directors.py index df611022c3..fec99563a7 100644 --- a/backend/apps/owasp/api/internal/queries/board_of_directors.py +++ b/backend/apps/owasp/api/internal/queries/board_of_directors.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.owasp.api.internal.nodes.board_of_directors import BoardOfDirectorsNode from apps.owasp.models.board_of_directors import BoardOfDirectors @@ -40,8 +41,7 @@ def boards_of_directors(self, limit: int = 10) -> list[BoardOfDirectorsNode]: List of BoardOfDirectorsNode objects. """ - return ( - BoardOfDirectors.objects.order_by("-year")[:limit] - if (limit := min(limit, MAX_LIMIT)) > 0 - else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return BoardOfDirectors.objects.order_by("-year")[:normalized_limit] diff --git a/backend/apps/owasp/api/internal/queries/chapter.py b/backend/apps/owasp/api/internal/queries/chapter.py index 64ecfd7f41..3bfa798dcd 100644 --- a/backend/apps/owasp/api/internal/queries/chapter.py +++ b/backend/apps/owasp/api/internal/queries/chapter.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.owasp.api.internal.nodes.chapter import ChapterNode from apps.owasp.models.chapter import Chapter @@ -24,8 +25,7 @@ def chapter(self, key: str) -> ChapterNode | None: @strawberry_django.field def recent_chapters(self, limit: int = 8) -> list[ChapterNode]: """Resolve recent chapters.""" - return ( - Chapter.active_chapters.order_by("-created_at")[:limit] - if (limit := min(limit, MAX_LIMIT)) > 0 - else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return Chapter.active_chapters.order_by("-created_at")[:normalized_limit] diff --git a/backend/apps/owasp/api/internal/queries/event.py b/backend/apps/owasp/api/internal/queries/event.py index 68dad6941a..f72d09fcb9 100644 --- a/backend/apps/owasp/api/internal/queries/event.py +++ b/backend/apps/owasp/api/internal/queries/event.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.owasp.api.internal.nodes.event import EventNode from apps.owasp.models.event import Event @@ -16,4 +17,7 @@ class EventQuery: @strawberry_django.field def upcoming_events(self, limit: int = 6) -> list[EventNode]: """Resolve upcoming events.""" - return Event.upcoming_events()[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return Event.upcoming_events()[:normalized_limit] diff --git a/backend/apps/owasp/api/internal/queries/member_snapshot.py b/backend/apps/owasp/api/internal/queries/member_snapshot.py index 4aac59e04e..cc896f304a 100644 --- a/backend/apps/owasp/api/internal/queries/member_snapshot.py +++ b/backend/apps/owasp/api/internal/queries/member_snapshot.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.github.models.user import User from apps.owasp.api.internal.nodes.member_snapshot import MemberSnapshotNode from apps.owasp.models.member_snapshot import MemberSnapshot @@ -67,6 +68,7 @@ def member_snapshots( except User.DoesNotExist: return [] - return ( - snapshots.order_by("-start_at")[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return snapshots.order_by("-start_at")[:normalized_limit] diff --git a/backend/apps/owasp/api/internal/queries/post.py b/backend/apps/owasp/api/internal/queries/post.py index a7f4ca3d23..f520359445 100644 --- a/backend/apps/owasp/api/internal/queries/post.py +++ b/backend/apps/owasp/api/internal/queries/post.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.owasp.api.internal.nodes.post import PostNode from apps.owasp.models.post import Post @@ -16,4 +17,7 @@ class PostQuery: @strawberry_django.field def recent_posts(self, limit: int = 5) -> list[PostNode]: """Return the most recent posts.""" - return Post.recent_posts()[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return Post.recent_posts()[:normalized_limit] diff --git a/backend/apps/owasp/api/internal/queries/project.py b/backend/apps/owasp/api/internal/queries/project.py index af4db3c009..6849c229a9 100644 --- a/backend/apps/owasp/api/internal/queries/project.py +++ b/backend/apps/owasp/api/internal/queries/project.py @@ -4,6 +4,7 @@ import strawberry_django from django.db.models import Q +from apps.common.utils import normalize_limit from apps.github.models.user import User as GithubUser from apps.owasp.api.internal.nodes.project import ProjectNode from apps.owasp.models.project import Project @@ -45,11 +46,10 @@ def recent_projects(self, limit: int = 8) -> list[ProjectNode]: list[ProjectNode]: A list of recent active projects. """ - return ( - Project.objects.filter(is_active=True).order_by("-created_at")[:limit] - if (limit := min(limit, MAX_RECENT_PROJECTS_LIMIT)) > 0 - else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_RECENT_PROJECTS_LIMIT)) is None: + return [] + + return Project.objects.filter(is_active=True).order_by("-created_at")[:normalized_limit] @strawberry_django.field def search_projects(self, query: str) -> list[ProjectNode]: diff --git a/backend/apps/owasp/api/internal/queries/snapshot.py b/backend/apps/owasp/api/internal/queries/snapshot.py index d649acad34..60cea2d366 100644 --- a/backend/apps/owasp/api/internal/queries/snapshot.py +++ b/backend/apps/owasp/api/internal/queries/snapshot.py @@ -3,6 +3,7 @@ import strawberry import strawberry_django +from apps.common.utils import normalize_limit from apps.owasp.api.internal.nodes.snapshot import SnapshotNode from apps.owasp.models.snapshot import Snapshot @@ -27,12 +28,9 @@ def snapshot(self, key: str) -> SnapshotNode | None: @strawberry_django.field def snapshots(self, limit: int = 12) -> list[SnapshotNode]: """Resolve snapshots.""" - return ( - Snapshot.objects.filter( - status=Snapshot.Status.COMPLETED, - ).order_by( - "-created_at", - )[:limit] - if (limit := min(limit, MAX_LIMIT)) > 0 - else [] - ) + if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None: + return [] + + return Snapshot.objects.filter( + status=Snapshot.Status.COMPLETED, + ).order_by("-created_at")[:normalized_limit] diff --git a/backend/tests/apps/common/utils_test.py b/backend/tests/apps/common/utils_test.py index 9b5d58dacd..e77fb12fb5 100644 --- a/backend/tests/apps/common/utils_test.py +++ b/backend/tests/apps/common/utils_test.py @@ -13,6 +13,7 @@ join_values, natural_date, natural_number, + normalize_limit, round_down, validate_url, ) @@ -196,3 +197,45 @@ def test_validate_url(self, url, expected): """Test the validate_url function.""" result = validate_url(url) assert result == expected + + @pytest.mark.parametrize( + ("limit", "max_limit", "expected"), + [ + (5, 1000, 5), + (100, 1000, 100), + (1000, 1000, 1000), + (1500, 1000, 1000), + (999, 1000, 999), + (0, 1000, None), + (-5, 1000, None), + (5, 10, 5), + (15, 10, 10), + (100, 50, 50), + (1, 1, 1), + ], + ) + def test_normalize_limit(self, limit, max_limit, expected): + """Test the normalize_limit function with valid integers.""" + assert normalize_limit(limit, max_limit) == expected + + @pytest.mark.parametrize( + ("limit", "max_limit"), + [ + ("invalid", 1000), + ("5.5", 1000), + (None, 1000), + ([], 1000), + ({}, 1000), + ], + ) + def test_normalize_limit_invalid_types(self, limit, max_limit): + """Test the normalize_limit function with invalid types.""" + assert normalize_limit(limit, max_limit) is None + + def test_normalize_limit_default_max_limit(self): + """Test the normalize_limit function with default max_limit.""" + assert normalize_limit(500) == 500 + assert normalize_limit(1000) == 1000 + assert normalize_limit(1500) == 1000 + assert normalize_limit(-1) is None + assert normalize_limit(0) is None