diff --git a/backend/apps/api/rest/v0/__init__.py b/backend/apps/api/rest/v0/__init__.py index ea80efb80b..738dff935b 100644 --- a/backend/apps/api/rest/v0/__init__.py +++ b/backend/apps/api/rest/v0/__init__.py @@ -2,9 +2,11 @@ from django.conf import settings from ninja import NinjaAPI, Swagger +from ninja.pagination import RouterPaginated from ninja.throttling import AuthRateThrottle from apps.api.rest.auth.api_key import ApiKey as ApiKey +from apps.api.rest.v0.chapter import router as chapter_router from apps.api.rest.v0.committee import router as committee_router from apps.api.rest.v0.event import router as event_router from apps.api.rest.v0.issue import router as issue_router @@ -15,8 +17,6 @@ from apps.api.rest.v0.repository import router as repository_router from apps.api.rest.v0.sponsor import router as sponsor_router -from .chapter import router as chapter_router - ROUTERS = { # Chapters. "/chapters": chapter_router, @@ -42,11 +42,12 @@ api_settings = { "auth": ApiKey(), # The `api_key` param name is based on the ApiKey class name. + "default_router": RouterPaginated(), "description": "Open Worldwide Application Security Project API", "docs": Swagger(settings={"persistAuthorization": True}), "throttle": [AuthRateThrottle("10/s")], "title": "OWASP Nest", - "version": "0.2.3", + "version": "0.2.4", } api_settings_customization = {} diff --git a/backend/apps/api/rest/v0/chapter.py b/backend/apps/api/rest/v0/chapter.py index ff2a314fb0..98d9b915b3 100644 --- a/backend/apps/api/rest/v0/chapter.py +++ b/backend/apps/api/rest/v0/chapter.py @@ -7,59 +7,71 @@ from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Path, Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated from ninja.responses import Response -from apps.owasp.models.chapter import Chapter +from apps.owasp.models.chapter import Chapter as ChapterModel -router = Router() +router = RouterPaginated(tags=["Chapters"]) -class ChapterErrorResponse(Schema): - """Chapter error response schema.""" +class ChapterBase(Schema): + """Base schema for Chapter (used in list endpoints).""" - message: str + created_at: datetime + key: str + name: str + updated_at: datetime + @staticmethod + def resolve_key(obj): + """Resolve key.""" + return obj.nest_key -class ChapterFilterSchema(FilterSchema): - """Filter schema for Chapter.""" - country: str | None = Field(None, description="Country of the chapter") - region: str | None = Field(None, description="Region of the chapter") +class Chapter(ChapterBase): + """Schema for Chapter (minimal fields for list display).""" -class ChapterSchema(Schema): - """Schema for Chapter.""" +class ChapterDetail(ChapterBase): + """Detail schema for Chapter (used in single item endpoints).""" country: str - created_at: datetime - name: str region: str - updated_at: datetime + + +class ChapterError(Schema): + """Chapter error schema.""" + + message: str + + +class ChapterFilter(FilterSchema): + """Filter for Chapter.""" + + country: str | None = Field(None, description="Country of the chapter") @router.get( "/", description="Retrieve a paginated list of OWASP chapters.", operation_id="list_chapters", - response={200: list[ChapterSchema]}, + response=list[Chapter], summary="List chapters", - tags=["Chapters"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_chapters( request: HttpRequest, - filters: ChapterFilterSchema = Query(...), + filters: ChapterFilter = Query(...), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, description="Ordering field", ), -) -> list[ChapterSchema]: +) -> list[Chapter]: """Get chapters.""" - return filters.filter(Chapter.active_chapters.order_by(ordering or "-created_at")) + return filters.filter(ChapterModel.active_chapters.order_by(ordering or "-created_at")) @router.get( @@ -67,18 +79,17 @@ def list_chapters( description="Retrieve chapter details.", operation_id="get_chapter", response={ - HTTPStatus.NOT_FOUND: ChapterErrorResponse, - HTTPStatus.OK: ChapterSchema, + HTTPStatus.NOT_FOUND: ChapterError, + HTTPStatus.OK: ChapterDetail, }, summary="Get chapter", - tags=["Chapters"], ) def get_chapter( request: HttpRequest, chapter_id: str = Path(example="London"), -) -> ChapterSchema | ChapterErrorResponse: +) -> ChapterDetail | ChapterError: """Get chapter.""" - if chapter := Chapter.active_chapters.filter( + if chapter := ChapterModel.active_chapters.filter( key__iexact=( chapter_id if chapter_id.startswith("www-chapter-") else f"www-chapter-{chapter_id}" ) diff --git a/backend/apps/api/rest/v0/committee.py b/backend/apps/api/rest/v0/committee.py index 7f77716206..532b7b5020 100644 --- a/backend/apps/api/rest/v0/committee.py +++ b/backend/apps/api/rest/v0/committee.py @@ -7,50 +7,63 @@ from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Path, Query, Router, Schema +from ninja import Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated from ninja.responses import Response -from apps.owasp.models.committee import Committee +from apps.owasp.models.committee import Committee as CommitteeModel -router = Router() +router = RouterPaginated(tags=["Committees"]) -class CommitteeErrorResponse(Schema): - """Committee error response schema.""" +class CommitteeBase(Schema): + """Base schema for Committee (used in list endpoints).""" - message: str + created_at: datetime + key: str + name: str + updated_at: datetime + @staticmethod + def resolve_key(obj): + """Resolve key.""" + return obj.nest_key -class CommitteeSchema(Schema): - """Schema for Committee.""" - name: str +class Committee(CommitteeBase): + """Schema for Committee (minimal fields for list display).""" + + +class CommitteeDetail(CommitteeBase): + """Detail schema for Committee (used in single item endpoints).""" + description: str - created_at: datetime - updated_at: datetime + + +class CommitteeError(Schema): + """Committee error schema.""" + + message: str @router.get( "/", description="Retrieve a paginated list of OWASP committees.", operation_id="list_committees", - response={200: list[CommitteeSchema]}, + response=list[Committee], summary="List committees", - tags=["Committees"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_committees( request: HttpRequest, ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, description="Ordering field", ), -) -> list[CommitteeSchema]: +) -> list[Committee]: """Get committees.""" - return Committee.active_committees.order_by(ordering or "-created_at") + return CommitteeModel.active_committees.order_by(ordering or "-created_at") @router.get( @@ -58,18 +71,17 @@ def list_committees( description="Retrieve committee details.", operation_id="get_committee", response={ - HTTPStatus.NOT_FOUND: CommitteeErrorResponse, - HTTPStatus.OK: CommitteeSchema, + HTTPStatus.NOT_FOUND: CommitteeError, + HTTPStatus.OK: CommitteeDetail, }, summary="Get committee", - tags=["Committees"], ) def get_chapter( request: HttpRequest, committee_id: str = Path(example="project"), -) -> CommitteeSchema | CommitteeErrorResponse: +) -> CommitteeDetail | CommitteeError: """Get chapter.""" - if committee := Committee.active_committees.filter( + if committee := CommitteeModel.active_committees.filter( is_active=True, key__iexact=( committee_id diff --git a/backend/apps/api/rest/v0/event.py b/backend/apps/api/rest/v0/event.py index e46f5c1703..0f8c678608 100644 --- a/backend/apps/api/rest/v0/event.py +++ b/backend/apps/api/rest/v0/event.py @@ -1,28 +1,46 @@ """Event API.""" from datetime import datetime +from http import HTTPStatus from typing import Literal from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Query, Router, Schema +from ninja import Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated +from ninja.responses import Response -from apps.owasp.models.event import Event +from apps.owasp.models.event import Event as EventModel -router = Router() +router = RouterPaginated(tags=["Events"]) -class EventSchema(Schema): - """Schema for Event.""" +class EventBase(Schema): + """Base schema for Event (used in list endpoints).""" - description: str + end_date: datetime | None = None + key: str name: str - end_date: datetime start_date: datetime - url: str + url: str | None = None + + +class Event(EventBase): + """Schema for Event (minimal fields for list display).""" + + +class EventDetail(EventBase): + """Detail schema for Event (used in single item endpoints).""" + + description: str | None = None + + +class EventError(Schema): + """Event error schema.""" + + message: str @router.get( @@ -30,17 +48,36 @@ class EventSchema(Schema): description="Retrieve a paginated list of OWASP events.", operation_id="list_events", summary="List events", - tags=["Events"], - response={200: list[EventSchema]}, + response=list[Event], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_events( request: HttpRequest, ordering: Literal["start_date", "-start_date", "end_date", "-end_date"] | None = Query( None, description="Ordering field", ), -) -> list[EventSchema]: +) -> list[Event]: """Get all events.""" - return Event.objects.order_by(ordering or "-start_date") + return EventModel.objects.order_by(ordering or "-start_date", "-end_date") + + +@router.get( + "/{str:event_id}", + description="Retrieve an event details.", + operation_id="get_event", + response={ + HTTPStatus.NOT_FOUND: EventError, + HTTPStatus.OK: EventDetail, + }, + summary="Get event", +) +def get_event( + request: HttpRequest, + event_id: str = Path(..., example="owasp-global-appsec-usa-2025-washington-dc"), +) -> EventDetail | EventError: + """Get event.""" + if event := EventModel.objects.filter(key__iexact=event_id).first(): + return event + + return Response({"message": "Event not found"}, status=HTTPStatus.NOT_FOUND) diff --git a/backend/apps/api/rest/v0/issue.py b/backend/apps/api/rest/v0/issue.py index 1c7c0db7bc..3d5c874d77 100644 --- a/backend/apps/api/rest/v0/issue.py +++ b/backend/apps/api/rest/v0/issue.py @@ -1,63 +1,120 @@ """Issue API.""" from datetime import datetime +from http import HTTPStatus from typing import Literal from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated +from ninja.responses import Response from apps.github.models.generic_issue_model import GenericIssueModel -from apps.github.models.issue import Issue +from apps.github.models.issue import Issue as IssueModel -router = Router(tags=["Issues"]) +router = RouterPaginated(tags=["Issues"]) -class IssueFilterSchema(FilterSchema): - """Filter schema for Issue.""" +class IssueBase(Schema): + """Base schema for Issue (used in list endpoints).""" - state: GenericIssueModel.State | None = Field( - None, - description="State of the issue", - ) - - -class IssueSchema(Schema): - """Schema for Issue.""" - - body: str created_at: datetime - title: str state: GenericIssueModel.State + title: str updated_at: datetime url: str +class Issue(IssueBase): + """Schema for Issue (minimal fields for list display).""" + + +class IssueDetail(IssueBase): + """Detail schema for Issue (used in single item endpoints).""" + + body: str + + +class IssueError(Schema): + """Issue error schema.""" + + message: str + + +class IssueFilter(FilterSchema): + """Filter for Issue.""" + + organization: str | None = Field( + None, + description="Organization that issues belong to (filtered by repository owner)", + example="OWASP", + ) + repository: str | None = Field( + None, + description="Repository that issues belong to", + example="Nest", + ) + state: GenericIssueModel.State | None = Field( + None, + description="Issue state", + ) + + @router.get( "/", description="Retrieve a paginated list of GitHub issues.", operation_id="list_issues", - response={200: list[IssueSchema]}, + response=list[Issue], summary="List issues", - tags=["Issues"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_issues( request: HttpRequest, - filters: IssueFilterSchema = Query(...), + filters: IssueFilter = Query(...), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, description="Ordering field", ), -) -> list[IssueSchema]: +) -> list[Issue]: """Get all issues.""" - issues = filters.filter(Issue.objects.all()) + issues = IssueModel.objects.select_related("repository", "repository__organization") + + if filters.organization: + issues = issues.filter(repository__organization__login__iexact=filters.organization) + + if filters.repository: + issues = issues.filter(repository__name__iexact=filters.repository) + if filters.state: + issues = issues.filter(state=filters.state) - if ordering: - issues = issues.order_by(ordering) + return issues.order_by(ordering or "-created_at", "-updated_at") - return issues + +@router.get( + "/{str:organization_id}/{str:repository_id}/{int:issue_id}", + description="Retrieve a specific GitHub issue by organization, repository, and issue number.", + operation_id="get_issue", + response={ + HTTPStatus.NOT_FOUND: IssueError, + HTTPStatus.OK: IssueDetail, + }, + summary="Get issue", +) +def get_issue( + request: HttpRequest, + organization_id: str = Path(example="OWASP"), + repository_id: str = Path(example="Nest"), + issue_id: int = Path(example=1234), +) -> IssueDetail | IssueError: + """Get a specific issue by organization, repository, and issue number.""" + try: + return IssueModel.objects.get( + repository__organization__login__iexact=organization_id, + repository__name__iexact=repository_id, + number=issue_id, + ) + except IssueModel.DoesNotExist: + return Response({"message": "Issue not found"}, status=HTTPStatus.NOT_FOUND) diff --git a/backend/apps/api/rest/v0/label.py b/backend/apps/api/rest/v0/label.py index 24def91e84..4760cf83f9 100644 --- a/backend/apps/api/rest/v0/label.py +++ b/backend/apps/api/rest/v0/label.py @@ -5,17 +5,34 @@ from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Query, Router, Schema +from ninja import Field, FilterSchema, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated -from apps.github.models.label import Label +from apps.github.models.label import Label as LabelModel -router = Router() +router = RouterPaginated(tags=["Labels"]) -class LabelFilterSchema(FilterSchema): - """Filter schema for Label.""" +class LabelBase(Schema): + """Base schema for Label (used in list endpoints).""" + + color: str + name: str + + +class Label(LabelBase): + """Schema for Label (minimal fields for list display).""" + + +class LabelDetail(LabelBase): + """Detail schema for Label (used in single item endpoints).""" + + description: str + + +class LabelFilter(FilterSchema): + """Filter for Label.""" color: str | None = Field( None, @@ -24,35 +41,25 @@ class LabelFilterSchema(FilterSchema): ) -class LabelSchema(Schema): - """Schema for Label.""" - - color: str - description: str - name: str - - @router.get( "/", description="Retrieve a paginated list of GitHub labels.", operation_id="list_labels", - response={200: list[LabelSchema]}, + response=list[Label], summary="List labels", - tags=["Labels"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_label( request: HttpRequest, - filters: LabelFilterSchema = Query(...), + filters: LabelFilter = Query(...), ordering: Literal["nest_created_at", "-nest_created_at", "nest_updated_at", "-nest_updated_at"] | None = Query( None, description="Ordering field", ), -) -> list[LabelSchema]: +) -> list[Label]: """Get all labels.""" - labels = filters.filter(Label.objects.all()) + labels = filters.filter(LabelModel.objects.all()) if ordering: labels = labels.order_by(ordering) diff --git a/backend/apps/api/rest/v0/member.py b/backend/apps/api/rest/v0/member.py index 9857491bb2..26d74e4bf3 100644 --- a/backend/apps/api/rest/v0/member.py +++ b/backend/apps/api/rest/v0/member.py @@ -7,71 +7,78 @@ from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Path, Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated from ninja.responses import Response -from apps.github.models.user import User +from apps.github.models.user import User as UserModel -router = Router() +router = RouterPaginated(tags=["Community"]) -class MemberFilterSchema(FilterSchema): - """Filter schema for User.""" +class MemberBase(Schema): + """Base schema for Member (used in list endpoints).""" - company: str | None = Field( - None, - description="Company of the user", - ) - location: str | None = Field(None, description="Location of the member") + avatar_url: str + created_at: datetime + login: str + name: str + updated_at: datetime + url: str -class MemberSchema(Schema): - """Schema for Member.""" +class Member(MemberBase): + """Schema for Member (minimal fields for list display).""" + + +class MemberDetail(MemberBase): + """Detail schema for Member (used in single item endpoints).""" - avatar_url: str bio: str company: str - created_at: datetime followers_count: int following_count: int location: str - login: str - name: str public_repositories_count: int title: str twitter_username: str - updated_at: datetime - url: str -class MemberErrorResponse(Schema): - """Member error response schema.""" +class MemberError(Schema): + """Member error schema.""" message: str +class MemberFilter(FilterSchema): + """Filter for User.""" + + company: str | None = Field( + None, + description="Company of the user", + ) + location: str | None = Field(None, description="Location of the member") + + @router.get( "/", description="Retrieve a paginated list of OWASP community members.", operation_id="list_members", - response={HTTPStatus.OK: list[MemberSchema]}, + response=list[Member], summary="List members", - tags=["Community"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_members( request: HttpRequest, - filters: MemberFilterSchema = Query(...), + filters: MemberFilter = Query(...), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, description="Ordering field", ), -) -> list[MemberSchema]: +) -> list[Member]: """Get all members.""" - return filters.filter(User.objects.order_by(ordering or "-created_at")) + return filters.filter(UserModel.objects.order_by(ordering or "-created_at")) @router.get( @@ -79,18 +86,17 @@ def list_members( description="Retrieve member details.", operation_id="get_member", response={ - HTTPStatus.NOT_FOUND: MemberErrorResponse, - HTTPStatus.OK: MemberSchema, + HTTPStatus.NOT_FOUND: MemberError, + HTTPStatus.OK: MemberDetail, }, summary="Get member", - tags=["Community"], ) def get_member( request: HttpRequest, member_id: str = Path(example="OWASP"), -) -> MemberSchema | MemberErrorResponse: +) -> MemberDetail | MemberError: """Get member.""" - if user := User.objects.filter(login__iexact=member_id).first(): + if user := UserModel.objects.filter(login__iexact=member_id).first(): return user return Response({"message": "Member not found"}, status=HTTPStatus.NOT_FOUND) diff --git a/backend/apps/api/rest/v0/organization.py b/backend/apps/api/rest/v0/organization.py index 46f9e54ff5..17bff8cdae 100644 --- a/backend/apps/api/rest/v0/organization.py +++ b/backend/apps/api/rest/v0/organization.py @@ -7,24 +7,44 @@ from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Path, Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated from ninja.responses import Response -from apps.github.models.organization import Organization +from apps.github.models.organization import Organization as OrganizationModel -router = Router() +router = RouterPaginated(tags=["Community"]) -class OrganizationErrorResponse(Schema): - """Organization error response schema.""" +class OrganizationBase(Schema): + """Base schema for Organization (used in list endpoints).""" + + created_at: datetime + login: str + name: str + updated_at: datetime + + +class Organization(OrganizationBase): + """Schema for Organization (minimal fields for list display).""" + + +class OrganizationDetail(OrganizationBase): + """Detail schema for Organization (used in single item endpoints).""" + + company: str + location: str + + +class OrganizationError(Schema): + """Organization error schema.""" message: str -class OrganizationFilterSchema(FilterSchema): - """Filter schema for Organization.""" +class OrganizationFilter(FilterSchema): + """Filter for Organization.""" location: str | None = Field( None, @@ -33,38 +53,25 @@ class OrganizationFilterSchema(FilterSchema): ) -class OrganizationSchema(Schema): - """Schema for Organization.""" - - company: str - created_at: datetime - location: str - login: str - name: str - updated_at: datetime - - @router.get( "/", description="Retrieve a paginated list of GitHub organizations.", operation_id="list_organizations", - response={200: list[OrganizationSchema]}, + response=list[Organization], summary="List organizations", - tags=["Community"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_organization( request: HttpRequest, - filters: OrganizationFilterSchema = Query(...), + filters: OrganizationFilter = Query(...), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, description="Ordering field", ), -) -> list[OrganizationSchema]: +) -> list[Organization]: """Get organizations.""" return filters.filter( - Organization.objects.filter( + OrganizationModel.objects.filter( is_owasp_related_organization=True, ).order_by(ordering or "-created_at") ) @@ -75,18 +82,17 @@ def list_organization( description="Retrieve project details.", operation_id="get_organization", response={ - HTTPStatus.NOT_FOUND: OrganizationErrorResponse, - HTTPStatus.OK: OrganizationSchema, + HTTPStatus.NOT_FOUND: OrganizationError, + HTTPStatus.OK: OrganizationDetail, }, summary="Get organization", - tags=["Community"], ) def get_organization( request: HttpRequest, organization_id: str = Path(example="OWASP"), -) -> OrganizationSchema | OrganizationErrorResponse: +) -> OrganizationDetail | OrganizationError: """Get project.""" - if organization := Organization.objects.filter( + if organization := OrganizationModel.objects.filter( is_owasp_related_organization=True, login__iexact=organization_id, ).first(): diff --git a/backend/apps/api/rest/v0/pagination.py b/backend/apps/api/rest/v0/pagination.py new file mode 100644 index 0000000000..c6c37fde73 --- /dev/null +++ b/backend/apps/api/rest/v0/pagination.py @@ -0,0 +1,58 @@ +"""Common pagination classes for v0 API.""" + +from typing import Any + +from django.http import Http404 +from ninja import Field, Schema +from ninja.pagination import PaginationBase + + +class CustomPagination(PaginationBase): + """Custom pagination with standardized output schema.""" + + items_attribute: str = "items" + + class Input(Schema): + """Input parameters for pagination.""" + + page: int = Field(1, ge=1, description="Page number") + page_size: int = Field(100, ge=1, le=100, description="Number of items per page") + + class Output(Schema): + """Standardized output schema for paginated responses.""" + + current_page: int = Field(description="Current page number") + has_next: bool = Field(description="Whether there is a next page") + has_previous: bool = Field(description="Whether there is a previous page") + items: list[Any] = Field(description="List of items") + total_count: int = Field(description="Total number of items") + total_pages: int = Field(description="Total number of pages") + + def paginate_queryset(self, queryset, pagination: Input, **params): + """Paginate the queryset and return standardized output.""" + page = pagination.page + page_size = pagination.page_size + + # Calculate pagination. + total_count = queryset.count() + # Ensure total_pages is at least 1 for consistent metadata. + total_pages = max(1, (total_count + page_size - 1) // page_size) + + # Validate that the requested page is within the valid range. + if page > total_pages: + message = f"Page {page} not found. Valid pages are 1 to {total_pages}." + raise Http404(message) + + offset = (page - 1) * page_size + + # Get the page items. + items = list(queryset[offset : offset + page_size]) + + return { + "current_page": page, + "has_next": page < total_pages, + "has_previous": page > 1, + "items": items, + "total_count": total_count, + "total_pages": total_pages, + } diff --git a/backend/apps/api/rest/v0/project.py b/backend/apps/api/rest/v0/project.py index e23ef7c0b6..b1d0c0e096 100644 --- a/backend/apps/api/rest/v0/project.py +++ b/backend/apps/api/rest/v0/project.py @@ -7,25 +7,50 @@ from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Path, Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated from ninja.responses import Response from apps.owasp.models.enums.project import ProjectLevel -from apps.owasp.models.project import Project +from apps.owasp.models.project import Project as ProjectModel -router = Router() +router = RouterPaginated(tags=["Projects"]) -class ProjectErrorResponse(Schema): - """Project error response schema.""" +class ProjectBase(Schema): + """Base schema for Project (used in list endpoints).""" + + created_at: datetime + key: str + level: ProjectLevel + name: str + updated_at: datetime + + @staticmethod + def resolve_key(obj): + """Resolve key.""" + return obj.nest_key + + +class Project(ProjectBase): + """Schema for Project (minimal fields for list display).""" + + +class ProjectDetail(ProjectBase): + """Detail schema for Project (used in single item endpoints).""" + + description: str + + +class ProjectError(Schema): + """Project error schema.""" message: str -class ProjectFilterSchema(FilterSchema): - """Filter schema for Project.""" +class ProjectFilter(FilterSchema): + """Filter for Project.""" level: ProjectLevel | None = Field( None, @@ -33,37 +58,25 @@ class ProjectFilterSchema(FilterSchema): ) -class ProjectSchema(Schema): - """Schema for Project.""" - - created_at: datetime - description: str - level: ProjectLevel - name: str - updated_at: datetime - - @router.get( "/", description="Retrieve a paginated list of OWASP projects.", operation_id="list_projects", - response={200: list[ProjectSchema]}, + response=list[Project], summary="List projects", - tags=["Projects"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_projects( request: HttpRequest, - filters: ProjectFilterSchema = Query(...), + filters: ProjectFilter = Query(...), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, description="Ordering field", example="-created_at", ), -) -> list[ProjectSchema]: +) -> list[Project]: """Get projects.""" - return filters.filter(Project.active_projects.order_by(ordering or "-created_at")) + return filters.filter(ProjectModel.active_projects.order_by(ordering or "-created_at")) @router.get( @@ -71,18 +84,17 @@ def list_projects( description="Retrieve project details.", operation_id="get_project", response={ - HTTPStatus.NOT_FOUND: ProjectErrorResponse, - HTTPStatus.OK: ProjectSchema, + HTTPStatus.NOT_FOUND: ProjectError, + HTTPStatus.OK: ProjectDetail, }, summary="Get project", - tags=["Projects"], ) def get_project( request: HttpRequest, project_id: str = Path(example="Nest"), -) -> ProjectSchema | ProjectErrorResponse: +) -> ProjectDetail | ProjectError: """Get project.""" - if project := Project.active_projects.filter( + if project := ProjectModel.active_projects.filter( key__iexact=( project_id if project_id.startswith("www-project-") else f"www-project-{project_id}" ) diff --git a/backend/apps/api/rest/v0/release.py b/backend/apps/api/rest/v0/release.py index fcad285119..119fa9dd0c 100644 --- a/backend/apps/api/rest/v0/release.py +++ b/backend/apps/api/rest/v0/release.py @@ -1,34 +1,61 @@ """Release API.""" from datetime import datetime +from http import HTTPStatus from typing import Literal from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated +from ninja.responses import Response -from apps.github.models.release import Release +from apps.github.models.release import Release as ReleaseModel -router = Router() +router = RouterPaginated(tags=["Releases"]) -class ReleaseFilterSchema(FilterSchema): - """Filter schema for Release.""" +class ReleaseBase(Schema): + """Base schema for Release (used in list endpoints).""" - tag_name: str | None = Field(None, description="Tag name of the release", example="v1.0.0") + created_at: datetime + name: str + published_at: datetime | None = None + tag_name: str -class ReleaseSchema(Schema): - """Schema for Release.""" +class Release(ReleaseBase): + """Schema for Release (minimal fields for list display).""" + + +class ReleaseDetail(ReleaseBase): + """Detail schema for Release (used in single item endpoints).""" - created_at: datetime description: str - name: str - published_at: datetime - tag_name: str + + +class ReleaseError(Schema): + """Release error schema.""" + + message: str + + +class ReleaseFilter(FilterSchema): + """Filter for Release.""" + + organization: str | None = Field( + None, + description="Organization that releases belong to (filtered by repository owner)", + example="OWASP", + ) + repository: str | None = Field( + None, + description="Repository that releases belong to", + example="Nest", + ) + tag_name: str | None = Field(None, description="Tag name of the release", example="0.2.10") @router.get( @@ -36,21 +63,59 @@ class ReleaseSchema(Schema): description="Retrieve a paginated list of GitHub releases.", operation_id="list_releases", summary="List releases", - tags=["Releases"], - response={200: list[ReleaseSchema]}, + response=list[Release], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_release( request: HttpRequest, - filters: ReleaseFilterSchema = Query(...), + filters: ReleaseFilter = Query(...), ordering: Literal["created_at", "-created_at", "published_at", "-published_at"] | None = Query( None, description="Ordering field", ), -) -> list[ReleaseSchema]: +) -> list[Release]: """Get all releases.""" - releases = filters.filter(Release.objects.all()) - if ordering: - releases = releases.order_by(ordering) - return releases + releases = ReleaseModel.objects.exclude( + published_at__isnull=True, + ).select_related( + "repository", + "repository__organization", + ) + + if filters.organization: + releases = releases.filter(repository__organization__login__iexact=filters.organization) + + if filters.repository: + releases = releases.filter(repository__name__iexact=filters.repository) + + if filters.tag_name: + releases = releases.filter(tag_name=filters.tag_name) + + return releases.order_by(ordering or "-published_at", "-created_at") + + +@router.get( + "/{str:organization_id}/{str:repository_id}/{str:release_id}", + description="Retrieve a specific GitHub release by organization, repository, and tag name.", + operation_id="get_release", + response={ + HTTPStatus.NOT_FOUND: ReleaseError, + HTTPStatus.OK: ReleaseDetail, + }, + summary="Get release", +) +def get_release( + request: HttpRequest, + organization_id: str = Path(example="OWASP"), + repository_id: str = Path(example="Nest"), + release_id: str = Path(example="0.2.10"), +) -> ReleaseDetail | ReleaseError: + """Get a specific release by organization, repository, and tag name.""" + try: + return ReleaseModel.objects.get( + repository__organization__login__iexact=organization_id, + repository__name__iexact=repository_id, + tag_name=release_id, + ) + except ReleaseModel.DoesNotExist: + return Response({"message": "Release not found"}, status=HTTPStatus.NOT_FOUND) diff --git a/backend/apps/api/rest/v0/repository.py b/backend/apps/api/rest/v0/repository.py index 509e5b6602..1809002e42 100644 --- a/backend/apps/api/rest/v0/repository.py +++ b/backend/apps/api/rest/v0/repository.py @@ -1,49 +1,101 @@ """Repository API.""" from datetime import datetime +from http import HTTPStatus from typing import Literal from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated +from ninja.responses import Response -from apps.github.models.repository import Repository +from apps.github.models.repository import Repository as RepositoryModel -router = Router() +router = RouterPaginated(tags=["Repositories"]) -class RepositorySchema(Schema): - """Schema for Repository.""" +class RepositoryBase(Schema): + """Base schema for Repository (used in list endpoints).""" created_at: datetime - description: str name: str updated_at: datetime +class Repository(RepositoryBase): + """Schema for Repository (minimal fields for list display).""" + + +class RepositoryDetail(RepositoryBase): + """Detail schema for Repository (used in single item endpoints).""" + + description: str | None = None + + +class RepositoryError(Schema): + """Repository error schema.""" + + message: str + + +class RepositoryFilter(FilterSchema): + """Filter for Repository.""" + + organization_id: str | None = Field( + None, + description="Organization that repositories belong to", + example="OWASP", + ) + + @router.get( "/", description="Retrieve a paginated list of GitHub repositories.", operation_id="list_repositories", summary="List repositories", - tags=["Repositories"], - response={200: list[RepositorySchema]}, + response=list[Repository], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_repository( request: HttpRequest, + filters: RepositoryFilter = Query(...), ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = Query( None, description="Ordering field", ), -) -> list[RepositorySchema]: +) -> list[Repository]: """Get all repositories.""" - repositories = Repository.objects.all() + repositories = RepositoryModel.objects.select_related("organization") + + if filters.organization_id: + repositories = repositories.filter(organization__login__iexact=filters.organization_id) + + return repositories.order_by(ordering or "-created_at", "-updated_at") - if ordering: - repositories = repositories.order_by(ordering) - return repositories + +@router.get( + "/{str:organization_id}/{str:repository_id}", + description="Retrieve a specific GitHub repository by organization and repository name.", + operation_id="get_repository", + response={ + HTTPStatus.NOT_FOUND: RepositoryError, + HTTPStatus.OK: RepositoryDetail, + }, + summary="Get repository", +) +def get_repository( + request: HttpRequest, + organization_id: str = Path(example="OWASP"), + repository_id: str = Path(example="Nest"), +) -> RepositoryDetail | RepositoryError: + """Get a specific repository by organization and repository name.""" + try: + return RepositoryModel.objects.select_related("organization").get( + organization__login__iexact=organization_id, + name__iexact=repository_id, + ) + except RepositoryModel.DoesNotExist: + return Response({"message": "Repository not found"}, status=HTTPStatus.NOT_FOUND) diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py index 63ae6925f6..a8261b27e2 100644 --- a/backend/apps/api/rest/v0/sponsor.py +++ b/backend/apps/api/rest/v0/sponsor.py @@ -6,30 +6,53 @@ from django.conf import settings from django.http import HttpRequest from django.views.decorators.cache import cache_page -from ninja import Field, FilterSchema, Path, Query, Router, Schema +from ninja import Field, FilterSchema, Path, Query, Schema from ninja.decorators import decorate_view -from ninja.pagination import PageNumberPagination, paginate +from ninja.pagination import RouterPaginated from ninja.responses import Response -from apps.owasp.models.sponsor import Sponsor +from apps.owasp.models.sponsor import Sponsor as SponsorModel -router = Router() +router = RouterPaginated(tags=["Sponsors"]) -class SponsorErrorResponse(Schema): - """Sponsor error response schema.""" +class SponsorBase(Schema): + """Base schema for Sponsor (used in list endpoints).""" + + image_url: str + key: str + name: str + sponsor_type: str + url: str + + +class Sponsor(SponsorBase): + """Schema for Sponsor (minimal fields for list display).""" + + +class SponsorDetail(SponsorBase): + """Detail schema for Sponsor (used in single item endpoints).""" + + description: str + is_member: bool + job_url: str + member_type: str + + +class SponsorError(Schema): + """Sponsor error schema.""" message: str -class SponsorFilterSchema(FilterSchema): - """Filter schema for Sponsor.""" +class SponsorFilter(FilterSchema): + """Filter for Sponsor.""" is_member: bool | None = Field( None, description="Member status of the sponsor", ) - member_type: Sponsor.MemberType | None = Field( + member_type: SponsorModel.MemberType | None = Field( None, description="Member type of the sponsor", ) @@ -41,59 +64,42 @@ class SponsorFilterSchema(FilterSchema): ) -class SponsorSchema(Schema): - """Schema for Sponsor.""" - - description: str - image_url: str - is_member: bool - job_url: str - key: str - member_type: str - name: str - sponsor_type: str - url: str - - @router.get( "/", description="Retrieve a paginated list of OWASP sponsors.", operation_id="list_sponsors", - response={HTTPStatus.OK: list[SponsorSchema]}, + response=list[Sponsor], summary="List sponsors", - tags=["Sponsors"], ) @decorate_view(cache_page(settings.API_CACHE_TIME_SECONDS)) -@paginate(PageNumberPagination, page_size=settings.API_PAGE_SIZE) def list_sponsors( request: HttpRequest, - filters: SponsorFilterSchema = Query(...), + filters: SponsorFilter = Query(...), ordering: Literal["name", "-name"] | None = Query( None, description="Ordering field", ), -) -> list[SponsorSchema]: +) -> list[Sponsor]: """Get sponsors.""" - return filters.filter(Sponsor.objects.order_by(ordering or "name")) + return filters.filter(SponsorModel.objects.order_by(ordering or "name")) @router.get( - "/{str:sponsor_key}", + "/{str:sponsor_id}", description="Retrieve a sponsor details.", operation_id="get_sponsor", response={ - HTTPStatus.NOT_FOUND: SponsorErrorResponse, - HTTPStatus.OK: SponsorSchema, + HTTPStatus.NOT_FOUND: SponsorError, + HTTPStatus.OK: SponsorDetail, }, summary="Get sponsor", - tags=["Sponsors"], ) def get_sponsor( request: HttpRequest, - sponsor_key: str = Path(..., example="adobe"), -) -> SponsorSchema | SponsorErrorResponse: + sponsor_id: str = Path(..., example="adobe"), +) -> SponsorDetail | SponsorError: """Get sponsor.""" - if sponsor := Sponsor.objects.filter(key__iexact=sponsor_key).first(): + if sponsor := SponsorModel.objects.filter(key__iexact=sponsor_id).first(): return sponsor return Response({"message": "Sponsor not found"}, status=HTTPStatus.NOT_FOUND) diff --git a/backend/apps/github/migrations/0037_rename_github_rele_created_d51966_idx_release_created_at_desc_idx_and_more.py b/backend/apps/github/migrations/0037_rename_github_rele_created_d51966_idx_release_created_at_desc_idx_and_more.py new file mode 100644 index 0000000000..13c608e721 --- /dev/null +++ b/backend/apps/github/migrations/0037_rename_github_rele_created_d51966_idx_release_created_at_desc_idx_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.7 on 2025-10-05 03:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0036_user_has_public_member_page_alter_organization_name_and_more"), + ] + + operations = [ + migrations.RenameIndex( + model_name="release", + new_name="release_created_at_desc_idx", + old_name="github_rele_created_d51966_idx", + ), + migrations.AddIndex( + model_name="issue", + index=models.Index(fields=["number"], name="github_issu_number_c195ce_idx"), + ), + migrations.AddIndex( + model_name="release", + index=models.Index(fields=["tag_name"], name="release_tag_name_idx"), + ), + migrations.AddIndex( + model_name="repository", + index=models.Index(fields=["name"], name="repository_name_idx"), + ), + ] diff --git a/backend/apps/github/models/issue.py b/backend/apps/github/models/issue.py index 693272a017..51947928d2 100644 --- a/backend/apps/github/models/issue.py +++ b/backend/apps/github/models/issue.py @@ -25,6 +25,7 @@ class Meta: db_table = "github_issues" indexes = [ models.Index(fields=["-created_at"]), + models.Index(fields=["number"]), ] ordering = ("-updated_at", "-state") verbose_name_plural = "Issues" diff --git a/backend/apps/github/models/release.py b/backend/apps/github/models/release.py index 91a0db87ab..33d110bc6d 100644 --- a/backend/apps/github/models/release.py +++ b/backend/apps/github/models/release.py @@ -13,8 +13,9 @@ class Release(BulkSaveModel, NodeModel, ReleaseIndexMixin, TimestampedModel): class Meta: db_table = "github_releases" indexes = [ - models.Index(fields=["-created_at"]), + models.Index(fields=["-created_at"], name="release_created_at_desc_idx"), models.Index(fields=["-published_at"], name="release_published_at_desc_idx"), + models.Index(fields=["tag_name"], name="release_tag_name_idx"), ] verbose_name_plural = "Releases" diff --git a/backend/apps/github/models/repository.py b/backend/apps/github/models/repository.py index 2cf299b7bb..ead41d6743 100644 --- a/backend/apps/github/models/repository.py +++ b/backend/apps/github/models/repository.py @@ -30,6 +30,9 @@ class Meta: models.UniqueConstraint(fields=("key", "owner"), name="unique_key_owner"), ] db_table = "github_repositories" + indexes = [ + models.Index(fields=["name"], name="repository_name_idx"), + ] verbose_name_plural = "Repositories" name = models.CharField(verbose_name="Name", max_length=100) diff --git a/backend/apps/owasp/migrations/0054_event_event_end_date_desc_idx.py b/backend/apps/owasp/migrations/0054_event_event_end_date_desc_idx.py new file mode 100644 index 0000000000..b274310ac8 --- /dev/null +++ b/backend/apps/owasp/migrations/0054_event_event_end_date_desc_idx.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.7 on 2025-10-05 03:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0053_entitychannel"), + ] + + operations = [ + migrations.AddIndex( + model_name="event", + index=models.Index(fields=["-end_date"], name="event_end_date_desc_idx"), + ), + ] diff --git a/backend/apps/owasp/models/event.py b/backend/apps/owasp/models/event.py index 8f0fd2c093..69385abb40 100644 --- a/backend/apps/owasp/models/event.py +++ b/backend/apps/owasp/models/event.py @@ -29,6 +29,7 @@ class Meta: db_table = "owasp_events" indexes = [ models.Index(fields=["-start_date"], name="event_start_date_desc_idx"), + models.Index(fields=["-end_date"], name="event_end_date_desc_idx"), ] verbose_name_plural = "Events" diff --git a/backend/settings/base.py b/backend/settings/base.py index 22b76ea900..10dfb2bd58 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -127,6 +127,8 @@ class Base(Configuration): API_PAGE_SIZE = 100 API_CACHE_TIME_SECONDS = 86400 # 24 hours. + NINJA_PAGINATION_CLASS = "apps.api.rest.v0.pagination.CustomPagination" + NINJA_PAGINATION_PER_PAGE = API_PAGE_SIZE REDIS_HOST = values.SecretValue(environ_name="REDIS_HOST") REDIS_PASSWORD = values.SecretValue(environ_name="REDIS_PASSWORD") diff --git a/backend/tests/apps/api/rest/v0/chapter_test.py b/backend/tests/apps/api/rest/v0/chapter_test.py index fd79b64e6f..b1757cd7c6 100644 --- a/backend/tests/apps/api/rest/v0/chapter_test.py +++ b/backend/tests/apps/api/rest/v0/chapter_test.py @@ -2,32 +2,42 @@ import pytest -from apps.api.rest.v0.chapter import ChapterSchema +from apps.api.rest.v0.chapter import ChapterDetail @pytest.mark.parametrize( "chapter_data", [ { - "name": "OWASP Nagoya", "country": "America", - "region": "Europe", "created_at": "2024-11-01T00:00:00Z", + "key": "nagoya", + "name": "OWASP Nagoya", + "region": "Europe", "updated_at": "2024-07-02T00:00:00Z", }, { - "name": "OWASP something", "country": "India", - "region": "Asia", "created_at": "2023-12-01T00:00:00Z", + "key": "something", + "name": "OWASP something", + "region": "Asia", "updated_at": "2023-09-02T00:00:00Z", }, ], ) def test_chapter_serializer_validation(chapter_data): - chapter = ChapterSchema(**chapter_data) - assert chapter.name == chapter_data["name"] + class MockChapter: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + self.nest_key = data["key"] + + chapter = ChapterDetail.from_orm(MockChapter(chapter_data)) + assert chapter.country == chapter_data["country"] - assert chapter.region == chapter_data["region"] assert chapter.created_at == datetime.fromisoformat(chapter_data["created_at"]) + assert chapter.key == chapter_data["key"] + assert chapter.name == chapter_data["name"] + assert chapter.region == chapter_data["region"] assert chapter.updated_at == datetime.fromisoformat(chapter_data["updated_at"]) diff --git a/backend/tests/apps/api/rest/v0/committee_test.py b/backend/tests/apps/api/rest/v0/committee_test.py index 0b43dc6332..22ef0e910b 100644 --- a/backend/tests/apps/api/rest/v0/committee_test.py +++ b/backend/tests/apps/api/rest/v0/committee_test.py @@ -2,30 +2,39 @@ import pytest -from apps.api.rest.v0.committee import CommitteeSchema +from apps.api.rest.v0.committee import CommitteeDetail @pytest.mark.parametrize( "committee_data", [ { - "name": "Test Committee", - "description": "A test committee", "created_at": "2024-11-01T00:00:00Z", + "description": "A test committee", + "key": "test-committee", + "name": "Test Committee", "updated_at": "2024-07-02T00:00:00Z", }, { - "name": "this is a committee", - "description": "A committee without a name", "created_at": "2023-12-01T00:00:00Z", + "description": "A committee without a name", + "key": "this-is-a-committee", + "name": "this is a committee", "updated_at": "2023-09-02T00:00:00Z", }, ], ) def test_committee_serializer_validation(committee_data): - committee = CommitteeSchema(**committee_data) + class MockCommittee: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + self.nest_key = data["key"] + + committee = CommitteeDetail.from_orm(MockCommittee(committee_data)) - assert committee.name == committee_data["name"] - assert committee.description == committee_data["description"] assert committee.created_at == datetime.fromisoformat(committee_data["created_at"]) + assert committee.description == committee_data["description"] + assert committee.key == committee_data["key"] + assert committee.name == committee_data["name"] assert committee.updated_at == datetime.fromisoformat(committee_data["updated_at"]) diff --git a/backend/tests/apps/api/rest/v0/event_test.py b/backend/tests/apps/api/rest/v0/event_test.py index ef4dee7623..d41b647aa7 100644 --- a/backend/tests/apps/api/rest/v0/event_test.py +++ b/backend/tests/apps/api/rest/v0/event_test.py @@ -2,32 +2,36 @@ import pytest -from apps.api.rest.v0.event import EventSchema +from apps.api.rest.v0.event import EventDetail @pytest.mark.parametrize( "event_data", [ { + "key": "test-event", "name": "Test Event", "description": "A test event", "url": "https://github.com/owasp/Nest", - "end_date": "2025-03-14T00:00:00", - "start_date": "2025-03-14T00:00:00", + "end_date": "2025-03-14T00:00:00Z", + "start_date": "2025-03-14T00:00:00Z", }, { + "key": "biggest-event", "name": "biggest event", "description": "this is a biggest event", "url": "https://github.com/owasp", - "end_date": "2023-05-18T00:00:00", - "start_date": "2022-05-19T00:00:00", + "end_date": "2023-05-18T00:00:00Z", + "start_date": "2022-05-19T00:00:00Z", }, ], ) def test_event_serializer_validation(event_data): - event = EventSchema(**event_data) - assert event.name == event_data["name"] + event = EventDetail(**event_data) + assert event.description == event_data["description"] - assert event.url == event_data["url"] assert event.end_date == datetime.fromisoformat(event_data["end_date"]) + assert event.key == event_data["key"] + assert event.name == event_data["name"] assert event.start_date == datetime.fromisoformat(event_data["start_date"]) + assert event.url == event_data["url"] diff --git a/backend/tests/apps/api/rest/v0/issue_test.py b/backend/tests/apps/api/rest/v0/issue_test.py index a73c2f3426..3211e4506e 100644 --- a/backend/tests/apps/api/rest/v0/issue_test.py +++ b/backend/tests/apps/api/rest/v0/issue_test.py @@ -2,7 +2,7 @@ import pytest -from apps.api.rest.v0.issue import IssueSchema +from apps.api.rest.v0.issue import IssueDetail class TestIssueSchema: @@ -10,30 +10,29 @@ class TestIssueSchema: "issue_data", [ { - "title": "Test Issue 1", "body": "This is a test issue 1", - "state": "open", - "url": "https://example.com/issues/1", "created_at": "2024-12-30T00:00:00Z", + "state": "open", + "title": "Test Issue 1", "updated_at": "2024-12-30T00:00:00Z", + "url": "https://example.com/issues/1", }, { - "title": "Test Issue 2", "body": "This is a test issue 2", - "state": "closed", - "url": "https://example.com/issues/2", "created_at": "2024-12-29T00:00:00Z", + "state": "closed", + "title": "Test Issue 2", "updated_at": "2024-12-30T00:00:00Z", + "url": "https://example.com/issues/2", }, ], ) def test_issue_schema(self, issue_data): - schema = IssueSchema(**issue_data) - - assert schema.title == issue_data["title"] - assert schema.body == issue_data["body"] - assert schema.state == issue_data["state"] - assert schema.url == issue_data["url"] + issue = IssueDetail(**issue_data) - assert schema.created_at == datetime.fromisoformat(issue_data["created_at"]) - assert schema.updated_at == datetime.fromisoformat(issue_data["updated_at"]) + assert issue.body == issue_data["body"] + assert issue.created_at == datetime.fromisoformat(issue_data["created_at"]) + assert issue.state == issue_data["state"] + assert issue.title == issue_data["title"] + assert issue.updated_at == datetime.fromisoformat(issue_data["updated_at"]) + assert issue.url == issue_data["url"] diff --git a/backend/tests/apps/api/rest/v0/label_test.py b/backend/tests/apps/api/rest/v0/label_test.py index 619222967a..037f36de40 100644 --- a/backend/tests/apps/api/rest/v0/label_test.py +++ b/backend/tests/apps/api/rest/v0/label_test.py @@ -1,6 +1,6 @@ import pytest -from apps.api.rest.v0.label import LabelSchema +from apps.api.rest.v0.label import LabelDetail class TestLabelSchema: @@ -20,7 +20,8 @@ class TestLabelSchema: ], ) def test_label_schema(self, label_data): - label = LabelSchema(**label_data) - assert label.name == label_data["name"] - assert label.description == label_data["description"] + label = LabelDetail(**label_data) + assert label.color == label_data["color"] + assert label.description == label_data["description"] + assert label.name == label_data["name"] diff --git a/backend/tests/apps/api/rest/v0/member_test.py b/backend/tests/apps/api/rest/v0/member_test.py index 762f04aeb5..8444702118 100644 --- a/backend/tests/apps/api/rest/v0/member_test.py +++ b/backend/tests/apps/api/rest/v0/member_test.py @@ -2,7 +2,7 @@ import pytest -from apps.api.rest.v0.member import MemberSchema +from apps.api.rest.v0.member import MemberDetail class TestMemberSchema: @@ -10,40 +10,40 @@ class TestMemberSchema: "member_data", [ { - "name": "John Doe", - "login": "johndoe", - "company": "GitHub", - "location": "San Francisco", "avatar_url": "https://github.com/images/johndoe.png", "bio": "Developer advocate", + "company": "GitHub", + "created_at": "2024-12-30T00:00:00Z", "email": "john@example.com", "followers_count": 10, "following_count": 5, + "location": "San Francisco", + "login": "johndoe", + "name": "John Doe", "public_repositories_count": 3, "title": "Senior Engineer", "twitter_username": "johndoe", - "url": "https://github.com/johndoe", - "created_at": "2024-12-30T00:00:00Z", "updated_at": "2024-12-30T00:00:00Z", + "url": "https://github.com/johndoe", }, ], ) def test_user_schema(self, member_data): - member = MemberSchema(**member_data) + member = MemberDetail(**member_data) - assert member.name == member_data["name"] - assert member.login == member_data["login"] - assert member.company == member_data["company"] - assert member.location == member_data["location"] assert member.avatar_url == member_data["avatar_url"] assert member.bio == member_data["bio"] + assert member.company == member_data["company"] + assert member.created_at == datetime.fromisoformat(member_data["created_at"]) assert member.followers_count == member_data["followers_count"] assert member.following_count == member_data["following_count"] + assert member.location == member_data["location"] + assert member.login == member_data["login"] + assert member.name == member_data["name"] assert member.public_repositories_count == member_data["public_repositories_count"] assert member.title == member_data["title"] assert member.twitter_username == member_data["twitter_username"] - assert member.url == member_data["url"] - assert member.created_at == datetime.fromisoformat(member_data["created_at"]) assert member.updated_at == datetime.fromisoformat(member_data["updated_at"]) + assert member.url == member_data["url"] assert not hasattr(member, "email") diff --git a/backend/tests/apps/api/rest/v0/organization_test.py b/backend/tests/apps/api/rest/v0/organization_test.py index d73c346d47..73ae839e66 100644 --- a/backend/tests/apps/api/rest/v0/organization_test.py +++ b/backend/tests/apps/api/rest/v0/organization_test.py @@ -2,7 +2,7 @@ import pytest -from apps.api.rest.v0.organization import OrganizationSchema +from apps.api.rest.v0.organization import OrganizationDetail class TestOrganizationSchema: @@ -10,29 +10,29 @@ class TestOrganizationSchema: "organization_data", [ { - "name": "GitHub", - "login": "github", "company": "GitHub, Inc.", - "location": "San Francisco, CA", "created_at": "2024-12-30T00:00:00Z", + "location": "San Francisco, CA", + "login": "github", + "name": "GitHub", "updated_at": "2024-12-30T00:00:00Z", }, { - "name": "Microsoft", - "login": "microsoft", "company": "Microsoft Corporation", - "location": "Redmond, WA", "created_at": "2024-12-29T00:00:00Z", + "location": "Redmond, WA", + "login": "microsoft", + "name": "Microsoft", "updated_at": "2024-12-30T00:00:00Z", }, ], ) def test_organization_schema(self, organization_data): - schema = OrganizationSchema(**organization_data) - assert schema.created_at == datetime.fromisoformat(organization_data["created_at"]) - assert schema.updated_at == datetime.fromisoformat(organization_data["updated_at"]) + organization = OrganizationDetail(**organization_data) - assert schema.name == organization_data["name"] - assert schema.login == organization_data["login"] - assert schema.company == organization_data["company"] - assert schema.location == organization_data["location"] + assert organization.company == organization_data["company"] + assert organization.created_at == datetime.fromisoformat(organization_data["created_at"]) + assert organization.location == organization_data["location"] + assert organization.login == organization_data["login"] + assert organization.name == organization_data["name"] + assert organization.updated_at == datetime.fromisoformat(organization_data["updated_at"]) diff --git a/backend/tests/apps/api/rest/v0/project_test.py b/backend/tests/apps/api/rest/v0/project_test.py index b9261b1bf2..112b41fb5c 100644 --- a/backend/tests/apps/api/rest/v0/project_test.py +++ b/backend/tests/apps/api/rest/v0/project_test.py @@ -2,32 +2,42 @@ import pytest -from apps.api.rest.v0.project import ProjectSchema +from apps.api.rest.v0.project import ProjectDetail @pytest.mark.parametrize( "project_data", [ { - "name": "another project", + "created_at": "2023-01-01T00:00:00Z", "description": "A test project by owasp", + "key": "another-project", "level": "other", - "created_at": "2023-01-01T00:00:00Z", + "name": "another project", "updated_at": "2023-01-02T00:00:00Z", }, { - "name": "this is a project", + "created_at": "2023-01-01T00:00:00Z", "description": "this is not a project, this is just a file", + "key": "this-is-a-project", "level": "incubator", - "created_at": "2023-01-01T00:00:00Z", + "name": "this is a project", "updated_at": "2023-01-02T00:00:00Z", }, ], ) def test_project_serializer_validation(project_data): - project = ProjectSchema(**project_data) - assert project.name == project_data["name"] + class MockProject: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + self.nest_key = data["key"] + + project = ProjectDetail.from_orm(MockProject(project_data)) + + assert project.created_at == datetime.fromisoformat(project_data["created_at"]) assert project.description == project_data["description"] + assert project.key == project_data["key"] assert project.level == project_data["level"] - assert project.created_at == datetime.fromisoformat(project_data["created_at"]) + assert project.name == project_data["name"] assert project.updated_at == datetime.fromisoformat(project_data["updated_at"]) diff --git a/backend/tests/apps/api/rest/v0/release_test.py b/backend/tests/apps/api/rest/v0/release_test.py index db4ffde029..8aa5d649a5 100644 --- a/backend/tests/apps/api/rest/v0/release_test.py +++ b/backend/tests/apps/api/rest/v0/release_test.py @@ -2,7 +2,7 @@ import pytest -from apps.api.rest.v0.release import ReleaseSchema +from apps.api.rest.v0.release import ReleaseDetail class TestReleaseSchema: @@ -10,26 +10,26 @@ class TestReleaseSchema: "release_data", [ { - "name": "v1.0", - "tag_name": "v1.0.0", - "description": "First stable release", "created_at": "2024-12-30T00:00:00Z", + "description": "First stable release", + "name": "v1.0", "published_at": "2024-12-30T00:00:00Z", + "tag_name": "v1.0.0", }, { - "name": "v1.1", - "tag_name": "v1.1.0", - "description": "Minor improvements and bug fixes", "created_at": "2024-12-29T00:00:00Z", + "description": "Minor improvements and bug fixes", + "name": "v1.1", "published_at": "2024-12-30T00:00:00Z", + "tag_name": "v1.1.0", }, ], ) def test_release_schema(self, release_data): - schema = ReleaseSchema(**release_data) - assert schema.created_at == datetime.fromisoformat(release_data["created_at"]) - assert schema.published_at == datetime.fromisoformat(release_data["published_at"]) + release = ReleaseDetail(**release_data) - assert schema.name == release_data["name"] - assert schema.tag_name == release_data["tag_name"] - assert schema.description == release_data["description"] + assert release.created_at == datetime.fromisoformat(release_data["created_at"]) + assert release.description == release_data["description"] + assert release.name == release_data["name"] + assert release.published_at == datetime.fromisoformat(release_data["published_at"]) + assert release.tag_name == release_data["tag_name"] diff --git a/backend/tests/apps/api/rest/v0/repository_test.py b/backend/tests/apps/api/rest/v0/repository_test.py index 40e11bbeca..35c896f9b6 100644 --- a/backend/tests/apps/api/rest/v0/repository_test.py +++ b/backend/tests/apps/api/rest/v0/repository_test.py @@ -2,7 +2,7 @@ import pytest -from apps.api.rest.v0.repository import RepositorySchema +from apps.api.rest.v0.repository import RepositoryDetail class TestRepositorySchema: @@ -10,23 +10,23 @@ class TestRepositorySchema: "repository_data", [ { - "name": "Repo1", - "description": "Description for Repo1", "created_at": "2024-12-30T00:00:00Z", + "description": "Description for Repo1", + "name": "Repo1", "updated_at": "2024-12-30T00:00:00Z", }, { - "name": "Repo2", - "description": "Description for Repo2", "created_at": "2024-12-29T00:00:00Z", + "description": "Description for Repo2", + "name": "Repo2", "updated_at": "2024-12-30T00:00:00Z", }, ], ) def test_repository_schema(self, repository_data): - repository = RepositorySchema(**repository_data) + repository = RepositoryDetail(**repository_data) - assert repository.name == repository_data["name"] - assert repository.description == repository_data["description"] assert repository.created_at == datetime.fromisoformat(repository_data["created_at"]) + assert repository.description == repository_data["description"] + assert repository.name == repository_data["name"] assert repository.updated_at == datetime.fromisoformat(repository_data["updated_at"]) diff --git a/backend/tests/apps/api/rest/v0/sponsor_test.py b/backend/tests/apps/api/rest/v0/sponsor_test.py index 83e70743bf..24a7ebadf4 100644 --- a/backend/tests/apps/api/rest/v0/sponsor_test.py +++ b/backend/tests/apps/api/rest/v0/sponsor_test.py @@ -1,6 +1,6 @@ import pytest -from apps.api.rest.v0.sponsor import SponsorSchema +from apps.api.rest.v0.sponsor import SponsorDetail class TestSponsorSchema: @@ -33,17 +33,17 @@ class TestSponsorSchema: ) def test_sponsor_schema_creation(self, sponsor_data): """Test schema creation with valid data.""" - schema_instance = SponsorSchema(**sponsor_data) + sponsor = SponsorDetail(**sponsor_data) - assert schema_instance.description == sponsor_data["description"] - assert schema_instance.image_url == sponsor_data["image_url"] - assert schema_instance.is_member == sponsor_data["is_member"] - assert schema_instance.job_url == sponsor_data["job_url"] - assert schema_instance.key == sponsor_data["key"] - assert schema_instance.member_type == sponsor_data["member_type"] - assert schema_instance.name == sponsor_data["name"] - assert schema_instance.sponsor_type == sponsor_data["sponsor_type"] - assert schema_instance.url == sponsor_data["url"] + assert sponsor.description == sponsor_data["description"] + assert sponsor.image_url == sponsor_data["image_url"] + assert sponsor.is_member == sponsor_data["is_member"] + assert sponsor.job_url == sponsor_data["job_url"] + assert sponsor.key == sponsor_data["key"] + assert sponsor.member_type == sponsor_data["member_type"] + assert sponsor.name == sponsor_data["name"] + assert sponsor.sponsor_type == sponsor_data["sponsor_type"] + assert sponsor.url == sponsor_data["url"] def test_sponsor_schema_with_minimal_data(self): """Test schema with minimal required fields.""" @@ -58,8 +58,8 @@ def test_sponsor_schema_with_minimal_data(self): "sponsor_type": "SILVER", "url": "", } - schema = SponsorSchema(**minimal_data) + sponsor = SponsorDetail(**minimal_data) - assert schema.job_url == "" - assert schema.key == "test-sponsor" - assert schema.name == "Test Sponsor" + assert sponsor.job_url == "" + assert sponsor.key == "test-sponsor" + assert sponsor.name == "Test Sponsor"