diff --git a/.github/workflows/run-ci-cd.yaml b/.github/workflows/run-ci-cd.yaml index ddc4ab28e0..1c6d0b6bc4 100644 --- a/.github/workflows/run-ci-cd.yaml +++ b/.github/workflows/run-ci-cd.yaml @@ -249,6 +249,29 @@ jobs: permissions: contents: read runs-on: ubuntu-latest + services: + db: + image: pgvector/pgvector:pg16 + env: + POSTGRES_DB: nest_db_e2e + POSTGRES_PASSWORD: nest_user_e2e_password + POSTGRES_USER: nest_user_e2e + options: >- + --health-cmd="pg_isready -U nest_user_e2e -d nest_db_e2e -h localhost -p 5432" + --health-interval=5s + --health-timeout=5s + --health-retries=5 + ports: + - 5432:5432 + cache: + image: redis:8.0.5-alpine3.21 + options: >- + --health-cmd="redis-cli ping" + --health-interval=5s + --health-retries=5 + --health-timeout=5s + ports: + - 6379:6379 steps: - name: Check out repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 @@ -256,6 +279,43 @@ jobs: - name: Set up Docker buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + - name: Setup Backend environment + uses: ./.github/workflows/setup-backend-environment + with: + db_username: nest_user_e2e + db_name: nest_db_e2e + + - name: Start Backend in the background + run: | + docker run -d --rm --name e2e-nest-backend \ + --env-file backend/.env.e2e.example \ + --network host \ + -e DJANGO_DB_HOST=localhost \ + -e DJANGO_REDIS_AUTH_ENABLED=False \ + -e DJANGO_REDIS_HOST=localhost \ + -p 9000:9000 \ + owasp/nest:test-backend-latest \ + sh -c ' + python manage.py migrate && + gunicorn wsgi:application --bind 0.0.0.0:9000 + ' + + - name: Waiting for the backend to be ready + run: | + timeout 5m bash -c ' + until wget --spider http://localhost:9000/a; do + echo "Waiting for backend..." + sleep 5 + done + ' + echo "Backend is up!" + + - name: Load Postgres data + env: + PGPASSWORD: nest_user_e2e_password + run: | + pg_restore -h localhost -U nest_user_e2e -d nest_db_e2e < backend/data/nest.dump + - name: Build frontend end-to-end testing image uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 with: @@ -270,7 +330,7 @@ jobs: - name: Run frontend end-to-end tests run: | - docker run --env-file frontend/.env.example owasp/nest:test-frontend-e2e-latest pnpm run test:e2e + docker run --env-file frontend/.env.e2e.example owasp/nest:test-frontend-e2e-latest pnpm run test:e2e timeout-minutes: 10 run-frontend-a11y-tests: @@ -324,6 +384,25 @@ jobs: fi timeout-minutes: 5 + run-graphql-fuzz-tests: + name: Run GraphQL fuzz tests + needs: + - scan-code + - scan-ci-dependencies + uses: ./.github/workflows/run-fuzz-tests.yaml + with: + test-file: graphql_test.py + + run-rest-fuzz-tests: + name: Run REST fuzz tests + needs: + - scan-code + - scan-ci-dependencies + uses: ./.github/workflows/run-fuzz-tests.yaml + with: + test-file: rest_test.py + rest-url: http://localhost:9500/api/v0 + build-staging-images: name: Build Staging Images env: diff --git a/.github/workflows/run-fuzz-tests.yaml b/.github/workflows/run-fuzz-tests.yaml new file mode 100644 index 0000000000..0de9a396b2 --- /dev/null +++ b/.github/workflows/run-fuzz-tests.yaml @@ -0,0 +1,118 @@ +name: Run fuzz tests + +on: + workflow_call: + inputs: + test-file: + description: 'The test file to run fuzz tests on' + required: true + type: string + rest-url: + description: 'The REST API URL to test against' + required: false + type: string + default: 'http://localhost:9500/api/v0' + +jobs: + run-fuzz-tests: + name: Run Fuzz Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + db: + image: pgvector/pgvector:pg16 + env: + POSTGRES_DB: nest_db_fuzz + POSTGRES_PASSWORD: nest_user_fuzz_password + POSTGRES_USER: nest_user_fuzz + options: >- + --health-cmd="pg_isready -U nest_user_fuzz -d nest_db_fuzz -h localhost -p 5432" + --health-interval=5s + --health-retries=5 + --health-timeout=5s + ports: + - 5432:5432 + cache: + image: redis:8.0.5-alpine3.21 + options: >- + --health-cmd="redis-cli ping" + --health-interval=5s + --health-retries=5 + --health-timeout=5s + ports: + - 6379:6379 + steps: + - name: Check out repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Setup Backend environment + uses: ./.github/workflows/setup-backend-environment + with: + db_username: nest_user_fuzz + db_name: nest_db_fuzz + + - name: Run backend with fuzz environment variables + run: | + docker run -d --rm --name fuzz-nest-backend \ + --env-file backend/.env.fuzz.example \ + --network host \ + -e DJANGO_DB_HOST=localhost \ + -e DJANGO_REDIS_AUTH_ENABLED=False \ + -e DJANGO_REDIS_HOST=localhost \ + -p 9500:9500 \ + owasp/nest:test-backend-latest \ + sh -c ' + python manage.py migrate && + gunicorn wsgi:application --bind 0.0.0.0:9500 + ' + + - name: Waiting for the backend to be ready + run: | + timeout 5m bash -c ' + until wget --spider http://localhost:9500/a; do + echo "Waiting for backend..." + sleep 5 + done + ' + echo "Backend is up!" + + - name: Load Postgres data + env: + PGPASSWORD: nest_user_fuzz_password + run: | + set -euo pipefail + if ! pg_restore -h localhost -U nest_user_fuzz -d nest_db_fuzz < backend/data/nest.dump; then + echo "Data loading failed" + exit 1 + fi + echo "Data loading completed." + + - name: Build Fuzz-testing image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + with: + cache-from: | + type=gha + type=registry,ref=owasp/nest:test-fuzz-backend-cache + cache-to: | + type=gha,compression=zstd + context: backend + file: docker/backend/Dockerfile.fuzz + load: true + platforms: linux/amd64 + tags: owasp/nest:test-fuzz-backend-latest + + - name: Run fuzz tests + env: + TEST_FILE: ${{ inputs.test-file }} + REST_URL: ${{ inputs.rest-url }} + run: | + docker run \ + --network host \ + -e BASE_URL=http://localhost:9500 \ + -e CI=true \ + -e REST_URL="$REST_URL" \ + -e TEST_FILE="$TEST_FILE" \ + owasp/nest:test-fuzz-backend-latest diff --git a/.github/workflows/setup-backend-environment/action.yaml b/.github/workflows/setup-backend-environment/action.yaml new file mode 100644 index 0000000000..5fcf9b9923 --- /dev/null +++ b/.github/workflows/setup-backend-environment/action.yaml @@ -0,0 +1,44 @@ +name: Set up Backend environment + +description: Sets up the Backend environment testing. + +inputs: + db_username: + description: 'Database username' + required: true + db_name: + description: 'Database name' + required: true + +runs: + using: composite + steps: + - name: Wait for database to be ready + env: + DB_USERNAME: ${{ inputs.db_username }} + DB_NAME: ${{ inputs.db_name }} + run: | + timeout 5m bash -c ' + until docker exec ${{ job.services.db.id }} pg_isready -U $DB_USERNAME -d $DB_NAME; do + echo "Waiting for database..." + sleep 5 + done + ' + shell: bash + + - name: Install PostgreSQL client + run: sudo apt-get install -y postgresql-client + shell: bash + + - name: Build backend image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + with: + cache-from: | + type=gha + cache-to: | + type=gha,compression=zstd + context: backend + file: docker/backend/Dockerfile + load: true + platforms: linux/amd64 + tags: owasp/nest:test-backend-latest diff --git a/.github/workflows/update-nest-test-images.yaml b/.github/workflows/update-nest-test-images.yaml index e56ce330ed..8c0bb4a0c8 100644 --- a/.github/workflows/update-nest-test-images.yaml +++ b/.github/workflows/update-nest-test-images.yaml @@ -74,4 +74,17 @@ jobs: platforms: linux/amd64 push: true tags: owasp/nest:test-frontend-e2e-latest + + - name: Build and push fuzz-test-backend image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + with: + cache-from: type=registry,ref=owasp/nest:test-fuzz-backend-cache + cache-to: | + type=gha,compression=zstd + type=registry,ref=owasp/nest:test-fuzz-backend-cache + context: backend + file: docker/backend/Dockerfile.fuzz + platforms: linux/amd64 + push: true + tags: owasp/nest:test-fuzz-backend-latest timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index a1828aa456..6780666977 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,12 @@ __pycache__ .cache .coverage .cursor/rules/snyk_rules.mdc +backend/fuzzing_results/ .DS_Store .env* !.env.example +!.env.e2e.example +!.env.fuzz.example .github/instructions/snyk_rules.instructions.md .idea .lighthouseci/ @@ -44,3 +47,6 @@ logs node_modules/ TODO venv/ + +# Snyk Security Extension - AI Rules (auto-generated) +.cursor/rules/snyk_rules.mdc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5c7f510ea..feaea6a6a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -275,6 +275,8 @@ Ensure that all `.env` files are saved in **UTF-8 format without BOM (Byte Order 1. **Load Initial Data**: + - Make sure you have `gzip` installed on your machine. + - Open a new terminal session and run the following command to populate the database with initial data from fixtures: ```bash @@ -404,6 +406,60 @@ make test This command runs tests and checks that coverage threshold requirements are satisfied for both backend and frontend. **Please note your PR won't be merged if it fails the code tests checks.** +### Running e2e Tests + +Run the frontend e2e tests with the following command: + +```bash +make test-frontend-e2e +``` + +This command automatically: + +- Starts the database and backend containers +- Runs migrations and loads test data +- Executes the e2e tests +- Cleans up containers when done + +For debugging, you can run the e2e backend separately: + +```bash +make run-backend-e2e +``` + +Then load data manually in another terminal: + +```bash +make load-data-e2e +``` + +### Running Fuzz Tests + +Run the fuzz tests with the following command: + +```bash +make test-fuzz +``` + +This command automatically: + +- Starts the database and backend containers +- Runs migrations and loads test data +- Executes the fuzz tests +- Cleans up containers when done + +For debugging, you can run the fuzz backend separately: + +```bash +make run-backend-fuzz +``` + +Then load data manually in another terminal: + +```bash +make load-data-fuzz +``` + ### Test Coverage - There is a **minimum test coverage requirement** for the **backend** code -- see [pyproject.toml](https://github.com/OWASP/Nest/blob/main/backend/pyproject.toml). diff --git a/backend/.env.e2e.example b/backend/.env.e2e.example new file mode 100644 index 0000000000..9b8e9d05ea --- /dev/null +++ b/backend/.env.e2e.example @@ -0,0 +1,24 @@ +DJANGO_ALGOLIA_APPLICATION_ID=None +DJANGO_ALGOLIA_EXCLUDED_LOCAL_INDEX_NAMES=None +DJANGO_ALGOLIA_WRITE_API_KEY=None +DJANGO_ALLOWED_HOSTS=* +DJANGO_AWS_ACCESS_KEY_ID=None +DJANGO_AWS_SECRET_ACCESS_KEY=None +DJANGO_SETTINGS_MODULE=settings.e2e +DJANGO_CONFIGURATION=E2E +DJANGO_DB_HOST=db +DJANGO_DB_NAME=nest_db_e2e +DJANGO_DB_USER=nest_user_e2e +DJANGO_DB_PASSWORD=nest_user_e2e_password +DJANGO_DB_PORT=5432 +DJANGO_OPEN_AI_SECRET_KEY=None +DJANGO_PUBLIC_IP_ADDRESS="127.0.0.1" +DJANGO_REDIS_AUTH_ENABLED=True +DJANGO_REDIS_HOST=cache +DJANGO_REDIS_PASSWORD=nest-cache-e2e-password +DJANGO_RELEASE_VERSION=None +DJANGO_SECRET_KEY=None +DJANGO_SENTRY_DSN=None +DJANGO_SLACK_BOT_TOKEN=None +DJANGO_SLACK_SIGNING_SECRET=None +GITHUB_TOKEN=None diff --git a/backend/.env.example b/backend/.env.example index 193d9df930..e1e27c8422 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,6 +13,7 @@ DJANGO_DB_USER=None DJANGO_ELEVENLABS_API_KEY=None DJANGO_OPEN_AI_SECRET_KEY=None DJANGO_PUBLIC_IP_ADDRESS="127.0.0.1" +DJANGO_REDIS_AUTH_ENABLED=True DJANGO_REDIS_HOST=None DJANGO_REDIS_PASSWORD=None DJANGO_RELEASE_VERSION=None diff --git a/backend/.env.fuzz.example b/backend/.env.fuzz.example new file mode 100644 index 0000000000..be2abbaa26 --- /dev/null +++ b/backend/.env.fuzz.example @@ -0,0 +1,24 @@ +DJANGO_ALGOLIA_APPLICATION_ID=None +DJANGO_ALGOLIA_EXCLUDED_LOCAL_INDEX_NAMES=None +DJANGO_ALGOLIA_WRITE_API_KEY=None +DJANGO_ALLOWED_HOSTS=* +DJANGO_AWS_ACCESS_KEY_ID=None +DJANGO_AWS_SECRET_ACCESS_KEY=None +DJANGO_SETTINGS_MODULE=settings.fuzz +DJANGO_CONFIGURATION=Fuzz +DJANGO_DB_HOST=db +DJANGO_DB_NAME=nest_db_fuzz +DJANGO_DB_USER=nest_user_fuzz +DJANGO_DB_PASSWORD=nest_user_fuzz_password +DJANGO_DB_PORT=5432 +DJANGO_OPEN_AI_SECRET_KEY=None +DJANGO_PUBLIC_IP_ADDRESS="127.0.0.1" +DJANGO_REDIS_AUTH_ENABLED=True +DJANGO_REDIS_HOST=cache +DJANGO_REDIS_PASSWORD=nest-fuzz-cache-password +DJANGO_RELEASE_VERSION=None +DJANGO_SECRET_KEY=None +DJANGO_SENTRY_DSN=None +DJANGO_SLACK_BOT_TOKEN=None +DJANGO_SLACK_SIGNING_SECRET=None +GITHUB_TOKEN=None diff --git a/backend/Makefile b/backend/Makefile index 2388c47f42..611f8be014 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -41,9 +41,36 @@ exec-backend-command: exec-backend-command-it: @docker exec -it nest-backend $(CMD) 2>/dev/null +exec-backend-command-e2e: + @docker exec -i e2e-nest-backend $(CMD) + +exec-backend-command-e2e-it: + @docker exec -it e2e-nest-backend $(CMD) + +exec-backend-command-fuzz: + @docker exec -i fuzz-nest-backend $(CMD) + +exec-backend-command-fuzz-it: + @docker exec -it fuzz-nest-backend $(CMD) + +exec-db-command: + @docker exec -i nest-db $(CMD) + exec-db-command-it: @docker exec -it nest-db $(CMD) +exec-db-command-e2e: + @docker exec -i e2e-nest-db $(CMD) + +exec-db-command-e2e-it: + @docker exec -it e2e-nest-db $(CMD) + +exec-db-command-fuzz: + @docker exec -i fuzz-nest-db $(CMD) + +exec-db-command-fuzz-it: + @docker exec -it fuzz-nest-db $(CMD) + clear-cache: @CMD="python manage.py clear_cache" $(MAKE) exec-backend-command @@ -55,18 +82,7 @@ django-shell: dump-data: @echo "Dumping Nest data" - @CMD="python manage.py dumpdata \ - github \ - owasp \ - slack.Conversation \ - slack.Member \ - slack.Message \ - slack.Workspace \ - --indent=4 \ - --natural-foreign \ - --natural-primary -o data/nest.json" $(MAKE) exec-backend-command - @CMD="sed -E -i 's/(\"[^\"]*email\"): *\"([^\"]|\\\")*\"/\1: \"\"/g' data/nest.json" $(MAKE) exec-backend-command - @CMD="gzip -f data/nest.json" $(MAKE) exec-backend-command + @CMD="python manage.py dump_data" $(MAKE) exec-backend-command-it enrich-data: \ github-enrich-issues \ @@ -86,7 +102,15 @@ index-data: load-data: @echo "Loading Nest data" - @CMD="python manage.py load_data" $(MAKE) exec-backend-command + @CMD="pg_restore -U nest_user_dev -d nest_db_dev < ./backend/data/nest.dump" $(MAKE) exec-db-command + +load-data-e2e: + @echo "Loading Nest e2e data" + @CMD="pg_restore -U nest_user_e2e -d nest_db_e2e < ./backend/data/nest.dump" $(MAKE) exec-db-command-e2e + +load-data-fuzz: + @echo "Loading Nest fuzz data" + @CMD="pg_restore -U nest_user_fuzz -d nest_db_fuzz < ./backend/data/nest.dump" $(MAKE) exec-db-command-fuzz merge-migrations: @CMD="python manage.py makemigrations --merge" $(MAKE) exec-backend-command @@ -97,9 +121,18 @@ migrate: migrations: @CMD="python manage.py makemigrations" $(MAKE) exec-backend-command +migrations-empty: + @CMD="python manage.py makemigrations --empty $(APP_NAME)" $(MAKE) exec-backend-command + purge-data: @CMD="python manage.py purge_data" $(MAKE) exec-backend-command +purge-data-e2e: + @CMD="python manage.py purge_data" $(MAKE) exec-backend-command-e2e + +purge-data-fuzz: + @CMD="python manage.py purge_data" $(MAKE) exec-backend-command-fuzz + recreate-schema: @echo "Recreating Nest schema" @CMD="psql -U nest_user_dev -d nest_db_dev -c \ @@ -111,6 +144,14 @@ restore-backup: @echo "Restoring Nest backup" @CMD="python manage.py restore_backup" $(MAKE) exec-backend-command +run-backend-e2e: + @DOCKER_BUILDKIT=1 \ + docker compose --project-name nest-e2e -f docker-compose/e2e/compose.yaml up --build --remove-orphans --abort-on-container-exit backend db cache + +run-backend-fuzz: + @COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ + docker compose --project-name nest-fuzz -f docker-compose/fuzz/compose.yaml up --build --remove-orphans --abort-on-container-exit backend db cache + save-backup: @echo "Saving Nest backup" @CMD="python manage.py dumpdata --natural-primary --natural-foreign --indent=2" $(MAKE) exec-backend-command > backend/data/backup.json @@ -145,6 +186,18 @@ test-backend: --env-file backend/.env.example \ --rm nest-test-backend pytest +test-fuzz: + @docker container rm -f fuzz-nest-db >/dev/null 2>&1 || true + @docker volume rm -f nest-fuzz_fuzz-db-data >/dev/null 2>&1 || true + @COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ + docker compose --project-name nest-fuzz -f docker-compose/fuzz/compose.yaml up --build --remove-orphans --abort-on-container-exit db cache backend data-loader + @echo "Running REST API fuzz tests..." + @COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ + docker compose --project-name nest-fuzz -f docker-compose/fuzz/compose.yaml up --build --remove-orphans --abort-on-container-exit db backend rest + @echo "Running GraphQL fuzz tests..." + @COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ + docker compose --project-name nest-fuzz -f docker-compose/fuzz/compose.yaml up --build --remove-orphans --abort-on-container-exit db cache backend graphql + update-backend-dependencies: @cd backend && poetry update diff --git a/backend/apps/api/rest/v0/__init__.py b/backend/apps/api/rest/v0/__init__.py index 93acf51a33..35f78e9c1f 100644 --- a/backend/apps/api/rest/v0/__init__.py +++ b/backend/apps/api/rest/v0/__init__.py @@ -4,6 +4,7 @@ from django.conf import settings from ninja import NinjaAPI, Swagger +from ninja.errors import ValidationError from ninja.pagination import RouterPaginated from ninja.throttling import AuthRateThrottle @@ -57,6 +58,28 @@ } ], } +elif settings.IS_E2E_ENVIRONMENT: + api_settings_customization = { + "auth": None, + "servers": [ + { + "description": "E2E", + "url": settings.SITE_URL, + } + ], + "throttle": [], + } +elif settings.IS_FUZZ_ENVIRONMENT: + api_settings_customization = { + "auth": None, + "servers": [ + { + "description": "Fuzz", + "url": settings.SITE_URL, + } + ], + "throttle": [], + } elif settings.IS_STAGING_ENVIRONMENT: api_settings_customization = { "servers": [ @@ -81,6 +104,16 @@ api = NinjaAPI(**{**api_settings, **api_settings_customization}) +@api.exception_handler(ValidationError) +def validation_exception_handler(request, exc): + """Handle validation exceptions.""" + return api.create_response( + request, + {"message": "Invalid request", "errors": exc.errors}, + status=400, + ) + + @api.get("/", include_in_schema=False) def api_root(request): """Handle API root endpoint requests.""" diff --git a/backend/apps/api/rest/v0/chapter.py b/backend/apps/api/rest/v0/chapter.py index be7d9290f1..28e8ebfdc6 100644 --- a/backend/apps/api/rest/v0/chapter.py +++ b/backend/apps/api/rest/v0/chapter.py @@ -11,7 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response -from apps.api.rest.v0.common import Leader, LocationFilter +from apps.api.rest.v0.common import Leader, LocationFilter, ValidationErrorSchema from apps.owasp.models.chapter import Chapter as ChapterModel router = RouterPaginated(tags=["Chapters"]) @@ -100,6 +100,7 @@ def list_chapters( description="Retrieve chapter details.", operation_id="get_chapter", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: ChapterError, HTTPStatus.OK: ChapterDetail, }, diff --git a/backend/apps/api/rest/v0/committee.py b/backend/apps/api/rest/v0/committee.py index 87eb0618cf..d14a9cbf0b 100644 --- a/backend/apps/api/rest/v0/committee.py +++ b/backend/apps/api/rest/v0/committee.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.owasp.models.committee import Committee as CommitteeModel router = RouterPaginated(tags=["Committees"]) @@ -70,6 +71,7 @@ def list_committees( description="Retrieve committee details.", operation_id="get_committee", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: CommitteeError, HTTPStatus.OK: CommitteeDetail, }, diff --git a/backend/apps/api/rest/v0/common.py b/backend/apps/api/rest/v0/common.py index 123207e086..55444ec6ea 100644 --- a/backend/apps/api/rest/v0/common.py +++ b/backend/apps/api/rest/v0/common.py @@ -10,6 +10,13 @@ class Leader(Schema): name: str +class ValidationErrorSchema(Schema): + """Schema for validation error.""" + + message: str + errors: list[dict] | dict | None = None + + class LocationFilter(FilterSchema): """Filter for Location.""" diff --git a/backend/apps/api/rest/v0/event.py b/backend/apps/api/rest/v0/event.py index 2672522de7..342d2d7304 100644 --- a/backend/apps/api/rest/v0/event.py +++ b/backend/apps/api/rest/v0/event.py @@ -1,6 +1,5 @@ """Event API.""" -from datetime import datetime from http import HTTPStatus from typing import Literal @@ -11,7 +10,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response -from apps.api.rest.v0.common import LocationFilter +from apps.api.rest.v0.common import LocationFilter, ValidationErrorSchema from apps.owasp.models.event import Event as EventModel router = RouterPaginated(tags=["Events"]) @@ -20,14 +19,24 @@ class EventBase(Schema): """Base schema for Event (used in list endpoints).""" - end_date: datetime | None = None + end_date: str | None = None key: str latitude: float | None = None longitude: float | None = None name: str - start_date: datetime + start_date: str url: str | None = None + @staticmethod + def resolve_end_date(event: EventModel) -> str | None: + """Resolve end date.""" + return event.end_date.isoformat() if event.end_date else None + + @staticmethod + def resolve_start_date(event: EventModel) -> str: + """Resolve start date.""" + return event.start_date.isoformat() + class Event(EventBase): """Schema for Event (minimal fields for list display).""" @@ -90,6 +99,7 @@ def list_events( description="Retrieve an event details.", operation_id="get_event", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: EventError, HTTPStatus.OK: EventDetail, }, diff --git a/backend/apps/api/rest/v0/issue.py b/backend/apps/api/rest/v0/issue.py index a249b2f039..1fdb615cf0 100644 --- a/backend/apps/api/rest/v0/issue.py +++ b/backend/apps/api/rest/v0/issue.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.github.models.generic_issue_model import GenericIssueModel from apps.github.models.issue import Issue as IssueModel @@ -97,6 +98,7 @@ def list_issues( description="Retrieve a specific GitHub issue by organization, repository, and issue number.", operation_id="get_issue", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: IssueError, HTTPStatus.OK: IssueDetail, }, diff --git a/backend/apps/api/rest/v0/member.py b/backend/apps/api/rest/v0/member.py index 249e74655d..ce0769d085 100644 --- a/backend/apps/api/rest/v0/member.py +++ b/backend/apps/api/rest/v0/member.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.github.models.user import User as UserModel router = RouterPaginated(tags=["Community"]) @@ -85,6 +86,7 @@ def list_members( description="Retrieve member details.", operation_id="get_member", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: MemberError, HTTPStatus.OK: MemberDetail, }, diff --git a/backend/apps/api/rest/v0/milestone.py b/backend/apps/api/rest/v0/milestone.py index 17ec54c197..c4761b6f0f 100644 --- a/backend/apps/api/rest/v0/milestone.py +++ b/backend/apps/api/rest/v0/milestone.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.github.models.generic_issue_model import GenericIssueModel from apps.github.models.milestone import Milestone as MilestoneModel @@ -101,6 +102,7 @@ def list_milestones( ), operation_id="get_milestone", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: MilestoneError, HTTPStatus.OK: MilestoneDetail, }, diff --git a/backend/apps/api/rest/v0/organization.py b/backend/apps/api/rest/v0/organization.py index b214307217..862274eab3 100644 --- a/backend/apps/api/rest/v0/organization.py +++ b/backend/apps/api/rest/v0/organization.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.github.models.organization import Organization as OrganizationModel router = RouterPaginated(tags=["Community"]) @@ -81,6 +82,7 @@ def list_organization( description="Retrieve project details.", operation_id="get_organization", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: OrganizationError, HTTPStatus.OK: OrganizationDetail, }, diff --git a/backend/apps/api/rest/v0/project.py b/backend/apps/api/rest/v0/project.py index 11d676321e..640530607a 100644 --- a/backend/apps/api/rest/v0/project.py +++ b/backend/apps/api/rest/v0/project.py @@ -11,7 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response -from apps.api.rest.v0.common import Leader +from apps.api.rest.v0.common import Leader, ValidationErrorSchema from apps.owasp.models.enums.project import ProjectLevel from apps.owasp.models.project import Project as ProjectModel @@ -96,6 +96,7 @@ def list_projects( description="Retrieve project details.", operation_id="get_project", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: ProjectError, HTTPStatus.OK: ProjectDetail, }, diff --git a/backend/apps/api/rest/v0/release.py b/backend/apps/api/rest/v0/release.py index 2d4c97d0bb..04073129e5 100644 --- a/backend/apps/api/rest/v0/release.py +++ b/backend/apps/api/rest/v0/release.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.github.models.release import Release as ReleaseModel router = RouterPaginated(tags=["Releases"]) @@ -98,6 +99,7 @@ def list_release( description="Retrieve a specific GitHub release by organization, repository, and tag name.", operation_id="get_release", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: ReleaseError, HTTPStatus.OK: ReleaseDetail, }, diff --git a/backend/apps/api/rest/v0/repository.py b/backend/apps/api/rest/v0/repository.py index 2e2214c23f..bbf652780f 100644 --- a/backend/apps/api/rest/v0/repository.py +++ b/backend/apps/api/rest/v0/repository.py @@ -11,6 +11,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.github.models.repository import Repository as RepositoryModel router = RouterPaginated(tags=["Repositories"]) @@ -85,6 +86,7 @@ def list_repository( description="Retrieve a specific GitHub repository by organization and repository name.", operation_id="get_repository", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: RepositoryError, HTTPStatus.OK: RepositoryDetail, }, diff --git a/backend/apps/api/rest/v0/snapshot.py b/backend/apps/api/rest/v0/snapshot.py index 50ef169d6c..a26ee516c8 100644 --- a/backend/apps/api/rest/v0/snapshot.py +++ b/backend/apps/api/rest/v0/snapshot.py @@ -12,6 +12,7 @@ from apps.api.decorators.cache import cache_response from apps.api.rest.v0.chapter import Chapter +from apps.api.rest.v0.common import ValidationErrorSchema from apps.api.rest.v0.issue import Issue from apps.api.rest.v0.member import Member from apps.api.rest.v0.project import Project @@ -130,6 +131,7 @@ def list_snapshots( description="Retrieve snapshot details.", operation_id="get_snapshot", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: SnapshotError, HTTPStatus.OK: SnapshotDetail, }, diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py index e00ab64612..4641e7639c 100644 --- a/backend/apps/api/rest/v0/sponsor.py +++ b/backend/apps/api/rest/v0/sponsor.py @@ -10,6 +10,7 @@ from ninja.responses import Response from apps.api.decorators.cache import cache_response +from apps.api.rest.v0.common import ValidationErrorSchema from apps.owasp.models.sponsor import Sponsor as SponsorModel router = RouterPaginated(tags=["Sponsors"]) @@ -88,6 +89,7 @@ def list_sponsors( description="Retrieve a sponsor details.", operation_id="get_sponsor", response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, HTTPStatus.NOT_FOUND: SponsorError, HTTPStatus.OK: SponsorDetail, }, diff --git a/backend/apps/common/management/commands/dump_data.py b/backend/apps/common/management/commands/dump_data.py new file mode 100644 index 0000000000..8c9631338c --- /dev/null +++ b/backend/apps/common/management/commands/dump_data.py @@ -0,0 +1,144 @@ +"""Dump masked data from the database into a compressed file.""" + +import contextlib +import os +from pathlib import Path +from subprocess import CalledProcessError, run + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from psycopg2 import OperationalError, ProgrammingError, connect, sql + +DEFAULT_DATABASE = settings.DATABASES["default"] +DB_HOST = DEFAULT_DATABASE.get("HOST", "localhost") +DB_PORT = str(DEFAULT_DATABASE.get("PORT", "5432")) +DB_USER = DEFAULT_DATABASE.get("USER", "") +DB_PASSWORD = DEFAULT_DATABASE.get("PASSWORD", "") +DB_NAME = DEFAULT_DATABASE.get("NAME", "") + + +class Command(BaseCommand): + help = "Create a dump of selected db tables." + + def add_arguments(self, parser): + parser.add_argument( + "--output", + default=str(Path(settings.BASE_DIR) / "data" / "nest.dump"), + help="Output dump path (default: data/nest.dump)", + ) + parser.add_argument( + "-t", + "--table", + action="append", + dest="tables", + default=[ + "public.owasp_*", + "public.github_*", + "public.slack_members", + "public.slack_workspaces", + "public.slack_conversations", + "public.slack_messages", + ], + help="Table pattern to include", + ) + + def handle(self, *args, **options): + output_path = Path(options["output"]).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + tables = options["tables"] or [] + + env = os.environ.copy() + env["PGPASSWORD"] = DB_PASSWORD + + temp_db = f"temp_{DB_NAME}" + try: + self._execute_sql( + "postgres", + [ + sql.SQL("CREATE DATABASE {temp_db} TEMPLATE {DB_NAME};").format( + temp_db=sql.Identifier(temp_db), DB_NAME=sql.Identifier(DB_NAME) + ) + ], + ) + + self.stdout.write(self.style.SUCCESS(f"Created temporary DB: {temp_db}")) + + table_list = self._execute_sql(temp_db, [self._table_list_query()]) + self._execute_sql(temp_db, self._remove_emails([row[0] for row in table_list])) + self.stdout.write(self.style.SUCCESS("Removed emails from temporary DB")) + + dump_cmd = [ + "pg_dump", + "-h", + DB_HOST, + "-p", + DB_PORT, + "-U", + DB_USER, + "-d", + temp_db, + "--compress=9", + "--data-only", + "--no-owner", + "--no-privileges", + "--format=custom", + ] + dump_cmd += [f"--table={table}" for table in tables] + dump_cmd += ["-f", str(output_path)] + + run(dump_cmd, check=True, env=env) + self.stdout.write(self.style.SUCCESS(f"Created dump: {output_path}")) + except CalledProcessError as e: + message = f"Command failed: {e.cmd}" + raise CommandError(message) from e + finally: + try: + self._execute_sql( + "postgres", + [ + sql.SQL("DROP DATABASE IF EXISTS {temp_db};").format( + temp_db=sql.Identifier(temp_db) + ) + ], + ) + except (ProgrammingError, OperationalError): + self.stderr.write( + self.style.WARNING(f"Failed to drop temp DB {temp_db} (ignored).") + ) + + def _table_list_query(self) -> sql.Composable: + return sql.SQL(""" + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'public' AND column_name = 'email'; + """) + + def _remove_emails(self, tables: list[str]) -> list[sql.Composable]: + return [ + sql.SQL("UPDATE {table} SET email = '';").format(table=sql.Identifier(table)) + for table in tables + ] + + def _execute_sql( + self, + dbname: str, + sql_queries: list[sql.Composable], + ): + connection = connect( + dbname=dbname, + user=DB_USER, + password=DB_PASSWORD, + host=DB_HOST, + port=DB_PORT, + ) + connection.autocommit = True + + rows = [] + with connection.cursor() as cursor: + for sql_query in sql_queries: + cursor.execute(sql_query) + with contextlib.suppress(ProgrammingError): + rows.extend(cursor.fetchall()) + connection.close() + + return rows diff --git a/backend/apps/common/management/commands/load_data.py b/backend/apps/common/management/commands/load_data.py deleted file mode 100644 index 4705ab873b..0000000000 --- a/backend/apps/common/management/commands/load_data.py +++ /dev/null @@ -1,17 +0,0 @@ -"""A command to load OWASP Nest data.""" - -from django.core.management import call_command -from django.core.management.base import BaseCommand -from django.db import transaction - -from apps.core.utils import index - - -class Command(BaseCommand): - help = "Load OWASP Nest data." - - def handle(self, *_args, **_options) -> None: - """Load data into the OWASP Nest application.""" - with index.disable_indexing(), transaction.atomic(): - # Run loaddata - call_command("loaddata", "data/nest.json.gz", "-v", "3") diff --git a/backend/apps/common/middlewares/__init__.py b/backend/apps/common/middlewares/__init__.py new file mode 100644 index 0000000000..a52b722e66 --- /dev/null +++ b/backend/apps/common/middlewares/__init__.py @@ -0,0 +1 @@ +"""OWASP Nest Common middlewares.""" diff --git a/backend/apps/common/middlewares/block_null_characters.py b/backend/apps/common/middlewares/block_null_characters.py new file mode 100644 index 0000000000..c9cc03ccb2 --- /dev/null +++ b/backend/apps/common/middlewares/block_null_characters.py @@ -0,0 +1,44 @@ +"""OWASP Nest middleware to block null characters in requests.""" + +import logging +from http import HTTPStatus + +from django.http import HttpRequest, JsonResponse + +logger = logging.getLogger(__name__) + +NULL_CHARACTER = "\x00" + + +class BlockNullCharactersMiddleware: + """BlockNullCharactersMiddleware to block requests containing null characters.""" + + def __init__(self, get_response): + """Initialize middleware with get_response callable.""" + self.get_response = get_response + + def __call__(self, request: HttpRequest): + """Process the request to block null characters.""" + if ( + NULL_CHARACTER in request.path + or NULL_CHARACTER in request.path_info + or any( + NULL_CHARACTER in value for values in request.GET.lists() for value in values[1] + ) + or any( + NULL_CHARACTER in value for values in request.POST.lists() for value in values[1] + ) + ): + message = ( + "Request contains null characters in URL or parameters which are not allowed." + ) + logger.warning(message) + return JsonResponse({"message": message, "errors": {}}, status=HTTPStatus.BAD_REQUEST) + + content_length = int(request.META.get("CONTENT_LENGTH", 0) or 0) + if content_length and (b"\x00" in request.body or b"\\u0000" in request.body): + message = "Request contains null characters in body which are not allowed." + logger.warning(message) + return JsonResponse({"message": message, "errors": {}}, status=HTTPStatus.BAD_REQUEST) + + return self.get_response(request) diff --git a/backend/apps/common/utils.py b/backend/apps/common/utils.py index ffb721bb96..7e434beb97 100644 --- a/backend/apps/common/utils.py +++ b/backend/apps/common/utils.py @@ -100,7 +100,7 @@ def get_user_ip_address(request: HttpRequest) -> str: str: The user's IP address. """ - if settings.IS_LOCAL_ENVIRONMENT: + if settings.IS_LOCAL_ENVIRONMENT or settings.IS_E2E_ENVIRONMENT: return settings.PUBLIC_IP_ADDRESS x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") diff --git a/backend/apps/github/api/internal/nodes/issue.py b/backend/apps/github/api/internal/nodes/issue.py index 0bc99c3557..0312c25cdf 100644 --- a/backend/apps/github/api/internal/nodes/issue.py +++ b/backend/apps/github/api/internal/nodes/issue.py @@ -22,51 +22,40 @@ class IssueNode(strawberry.relay.Node): """GitHub issue node.""" - @strawberry.field - def author(self) -> UserNode | None: - """Resolve author.""" - return self.author + assignees: list[UserNode] = strawberry_django.field() + author: UserNode | None = strawberry_django.field() + pull_requests: list[PullRequestNode] = strawberry_django.field() - @strawberry.field - def organization_name(self) -> str | None: + @strawberry_django.field(select_related=["repository__organization", "repository"]) + def organization_name(self, root: Issue) -> str | None: """Resolve organization name.""" return ( - self.repository.organization.login - if self.repository and self.repository.organization + root.repository.organization.login + if root.repository and root.repository.organization else None ) - @strawberry.field - def repository_name(self) -> str | None: + @strawberry_django.field(select_related=["repository"]) + def repository_name(self, root: Issue) -> str | None: """Resolve the repository name.""" - return self.repository.name if self.repository else None + return root.repository.name if root.repository else None - @strawberry.field - def assignees(self) -> list[UserNode]: - """Resolve assignees list.""" - return list(self.assignees.all()) - - @strawberry.field - def labels(self) -> list[str]: + @strawberry_django.field(prefetch_related=["labels"]) + def labels(self, root: Issue) -> list[str]: """Resolve label names for the issue.""" - return list(self.labels.values_list("name", flat=True)) + return [label.name for label in root.labels.all()] - @strawberry.field - def is_merged(self) -> bool: + @strawberry_django.field(prefetch_related=["pull_requests"]) + def is_merged(self, root: Issue) -> bool: """Return True if this issue has at least one merged pull request.""" - return self.pull_requests.filter(state="closed", merged_at__isnull=False).exists() + return root.pull_requests.filter(state="closed", merged_at__isnull=False).exists() - @strawberry.field - def interested_users(self) -> list[UserNode]: + @strawberry_django.field(prefetch_related=["participant_interests__user"]) + def interested_users(self, root: Issue) -> list[UserNode]: """Return all users who have expressed interest in this issue.""" return [ interest.user - for interest in self.participant_interests.select_related("user").order_by( + for interest in root.participant_interests.select_related("user").order_by( "user__login" ) ] - - @strawberry.field - def pull_requests(self) -> list[PullRequestNode]: - """Return all pull requests linked to this issue.""" - return list(self.pull_requests.select_related("author", "repository").all()) diff --git a/backend/apps/github/api/internal/nodes/milestone.py b/backend/apps/github/api/internal/nodes/milestone.py index 17b5d67f2c..17d7db1159 100644 --- a/backend/apps/github/api/internal/nodes/milestone.py +++ b/backend/apps/github/api/internal/nodes/milestone.py @@ -22,29 +22,27 @@ class MilestoneNode(strawberry.relay.Node): """Github Milestone Node.""" - @strawberry.field - def author(self) -> UserNode | None: - """Resolve author.""" - return self.author + author: UserNode | None = strawberry_django.field() - @strawberry.field - def organization_name(self) -> str | None: + @strawberry_django.field(select_related=["repository__organization"]) + def organization_name(self, root: Milestone) -> str | None: """Resolve organization name.""" return ( - self.repository.organization.login - if self.repository and self.repository.organization + root.repository.organization.login + if root.repository and root.repository.organization else None ) - @strawberry.field - def progress(self) -> float: + @strawberry_django.field + def progress(self, root: Milestone) -> float: """Resolve milestone progress.""" - total_issues_count = self.closed_issues_count + self.open_issues_count - if not total_issues_count: - return 0.0 - return round((self.closed_issues_count / total_issues_count) * 100, 2) + return ( + round((root.closed_issues_count / total_issues_count) * 100, 2) + if (total_issues_count := root.closed_issues_count + root.open_issues_count) + else 0.0 + ) - @strawberry.field - def repository_name(self) -> str | None: + @strawberry_django.field(select_related=["repository"]) + def repository_name(self, root: Milestone) -> str | None: """Resolve repository name.""" - return self.repository.name if self.repository else None + return root.repository.name if root.repository else None diff --git a/backend/apps/github/api/internal/nodes/organization.py b/backend/apps/github/api/internal/nodes/organization.py index a249846bc7..08b53e7ad1 100644 --- a/backend/apps/github/api/internal/nodes/organization.py +++ b/backend/apps/github/api/internal/nodes/organization.py @@ -39,10 +39,10 @@ class OrganizationStatsNode: class OrganizationNode(strawberry.relay.Node): """GitHub organization node.""" - @strawberry.field - def stats(self) -> OrganizationStatsNode: + @strawberry_django.field + def stats(self, root: Organization) -> OrganizationStatsNode: """Resolve organization stats.""" - repositories = Repository.objects.filter(organization=self) + repositories = Repository.objects.filter(organization=root) total_repositories = repositories.count() aggregated_stats = repositories.aggregate( @@ -70,7 +70,7 @@ def stats(self) -> OrganizationStatsNode: total_issues=total_issues, ) - @strawberry.field - def url(self) -> str: + @strawberry_django.field + def url(self, root: Organization) -> str: """Resolve organization URL.""" - return self.url + return root.url diff --git a/backend/apps/github/api/internal/nodes/pull_request.py b/backend/apps/github/api/internal/nodes/pull_request.py index 7cd117e9fe..4e42bea9af 100644 --- a/backend/apps/github/api/internal/nodes/pull_request.py +++ b/backend/apps/github/api/internal/nodes/pull_request.py @@ -19,26 +19,23 @@ class PullRequestNode(strawberry.relay.Node): """GitHub pull request node.""" - @strawberry.field - def author(self) -> UserNode | None: - """Resolve author.""" - return self.author + author: UserNode | None = strawberry_django.field() - @strawberry.field - def organization_name(self) -> str | None: + @strawberry_django.field(select_related=["repository__organization"]) + def organization_name(self, root: PullRequest) -> str | None: """Resolve organization name.""" return ( - self.repository.organization.login - if self.repository and self.repository.organization + root.repository.organization.login + if root.repository and root.repository.organization else None ) - @strawberry.field - def repository_name(self) -> str | None: + @strawberry_django.field(select_related=["repository"]) + def repository_name(self, root: PullRequest) -> str | None: """Resolve repository name.""" - return self.repository.name if self.repository else None + return root.repository.name if root.repository else None - @strawberry.field - def url(self) -> str: + @strawberry_django.field + def url(self, root: PullRequest) -> str: """Resolve URL.""" - return str(self.url) if self.url else "" + return root.url diff --git a/backend/apps/github/api/internal/nodes/release.py b/backend/apps/github/api/internal/nodes/release.py index 0bf8e78cdf..36170bcddb 100644 --- a/backend/apps/github/api/internal/nodes/release.py +++ b/backend/apps/github/api/internal/nodes/release.py @@ -20,35 +20,34 @@ class ReleaseNode(strawberry.relay.Node): """GitHub release node.""" - @strawberry.field - def author(self) -> UserNode | None: - """Resolve author.""" - return self.author + author: UserNode | None = strawberry_django.field() - @strawberry.field - def organization_name(self) -> str | None: + @strawberry_django.field(select_related=["repository__organization"]) + def organization_name(self, root: Release) -> str | None: """Resolve organization name.""" return ( - self.repository.organization.login - if self.repository and self.repository.organization + root.repository.organization.login + if root.repository and root.repository.organization else None ) - @strawberry.field - def project_name(self) -> str | None: + @strawberry_django.field( + select_related=["repository"], prefetch_related=["repository__project_set"] + ) + def project_name(self, root: Release) -> str | None: """Resolve project name.""" return ( - self.repository.project.name.lstrip(OWASP_ORGANIZATION_NAME) - if self.repository and self.repository.project + root.repository.project.name.lstrip(OWASP_ORGANIZATION_NAME) + if root.repository and root.repository.project else None ) - @strawberry.field - def repository_name(self) -> str | None: + @strawberry_django.field(select_related=["repository"]) + def repository_name(self, root: Release) -> str | None: """Resolve repository name.""" - return self.repository.name if self.repository else None + return root.repository.name if root.repository else None - @strawberry.field - def url(self) -> str: + @strawberry_django.field + def url(self, root: Release) -> str: """Resolve URL.""" - return self.url + return root.url diff --git a/backend/apps/github/api/internal/nodes/repository.py b/backend/apps/github/api/internal/nodes/repository.py index e0791fe4c4..6a8c687d01 100644 --- a/backend/apps/github/api/internal/nodes/repository.py +++ b/backend/apps/github/api/internal/nodes/repository.py @@ -41,61 +41,53 @@ class RepositoryNode(strawberry.relay.Node): """Repository node.""" - @strawberry.field - def issues(self) -> list[IssueNode]: + organization: OrganizationNode | None = strawberry_django.field() + + @strawberry_django.field(prefetch_related=["issues"]) + def issues(self, root: Repository) -> list[IssueNode]: """Resolve recent issues.""" # TODO(arkid15r): rename this to recent_issues. - return self.issues.select_related("author").order_by("-created_at")[:RECENT_ISSUES_LIMIT] + return root.issues.order_by("-created_at")[:RECENT_ISSUES_LIMIT] - @strawberry.field - def languages(self) -> list[str]: + @strawberry_django.field + def languages(self, root: Repository) -> list[str]: """Resolve languages.""" - return list(self.languages.keys()) + return list(root.languages.keys()) - @strawberry.field - def latest_release(self) -> str: + @strawberry_django.field + def latest_release(self, root: Repository) -> str | None: """Resolve latest release.""" - return self.latest_release - - @strawberry.field - def organization(self) -> OrganizationNode | None: - """Resolve organization.""" - return self.organization - - @strawberry.field - def owner_key(self) -> str: - """Resolve owner key.""" - return self.owner_key + return root.latest_release - @strawberry.field + @strawberry_django.field(prefetch_related=["project_set"]) def project( - self, + self, root: Repository ) -> Annotated["ProjectNode", strawberry.lazy("apps.owasp.api.internal.nodes.project")] | None: """Resolve project.""" - return self.project + return root.project - @strawberry.field - def recent_milestones(self, limit: int = 5) -> list[MilestoneNode]: + @strawberry_django.field(prefetch_related=["milestones"]) + def recent_milestones(self, root: Repository, limit: int = 5) -> list[MilestoneNode]: """Resolve recent milestones.""" - return self.recent_milestones.select_related("repository").order_by("-created_at")[:limit] + return root.recent_milestones.order_by("-created_at")[:limit] - @strawberry.field - def releases(self) -> list[ReleaseNode]: + @strawberry_django.field(prefetch_related=["releases"]) + def releases(self, root: Repository) -> list[ReleaseNode]: """Resolve recent releases.""" # TODO(arkid15r): rename this to recent_releases. - return self.published_releases.order_by("-published_at")[:RECENT_RELEASES_LIMIT] + return root.published_releases.order_by("-published_at")[:RECENT_RELEASES_LIMIT] - @strawberry.field - def top_contributors(self) -> list[RepositoryContributorNode]: + @strawberry_django.field + def top_contributors(self, root: Repository) -> list[RepositoryContributorNode]: """Resolve top contributors.""" - return self.idx_top_contributors + return [RepositoryContributorNode(**tc) for tc in root.idx_top_contributors] - @strawberry.field - def topics(self) -> list[str]: + @strawberry_django.field + def topics(self, root: Repository) -> list[str]: """Resolve topics.""" - return self.topics + return root.topics - @strawberry.field - def url(self) -> str: + @strawberry_django.field + def url(self, root: Repository) -> str: """Resolve URL.""" - return self.url + return root.url diff --git a/backend/apps/github/api/internal/nodes/repository_contributor.py b/backend/apps/github/api/internal/nodes/repository_contributor.py index 6a43fb976f..21c9132ad5 100644 --- a/backend/apps/github/api/internal/nodes/repository_contributor.py +++ b/backend/apps/github/api/internal/nodes/repository_contributor.py @@ -12,5 +12,5 @@ class RepositoryContributorNode: id: strawberry.ID login: str name: str - project_key: str - project_name: str + project_key: str | None = None + project_name: str | None = None diff --git a/backend/apps/github/api/internal/nodes/user.py b/backend/apps/github/api/internal/nodes/user.py index c305d7764e..e7f635cbfc 100644 --- a/backend/apps/github/api/internal/nodes/user.py +++ b/backend/apps/github/api/internal/nodes/user.py @@ -1,6 +1,5 @@ """GitHub user GraphQL node.""" -import strawberry import strawberry_django from apps.github.models.user import User @@ -28,21 +27,19 @@ class UserNode: """GitHub user node.""" - @strawberry.field - def badge_count(self) -> int: + @strawberry_django.field(prefetch_related=["user_badges"]) + def badge_count(self, root: User) -> int: """Resolve badge count.""" - return self.user_badges.filter(is_active=True).count() + return root.user_badges.filter(is_active=True).count() - @strawberry.field - def badges(self) -> list[BadgeNode]: + @strawberry_django.field(prefetch_related=["user_badges__badge"]) + def badges(self, root: User) -> list[BadgeNode]: """Return user badges.""" user_badges = ( - self.user_badges.filter( + root.user_badges.filter( is_active=True, ) - .select_related( - "badge", - ) + .select_related("badge") .order_by( "badge__weight", "badge__name", @@ -50,62 +47,64 @@ def badges(self) -> list[BadgeNode]: ) return [user_badge.badge for user_badge in user_badges] - @strawberry.field - def created_at(self) -> float: + @strawberry_django.field + def created_at(self, root: User) -> float: """Resolve created at.""" - return self.idx_created_at + return root.idx_created_at - @strawberry.field - def first_owasp_contribution_at(self) -> float | None: + @strawberry_django.field(select_related=["owasp_profile"]) + def first_owasp_contribution_at(self, root: User) -> float | None: """Resolve first OWASP contribution date.""" - if hasattr(self, "owasp_profile") and self.owasp_profile.first_contribution_at: - return self.owasp_profile.first_contribution_at.timestamp() - return None + return ( + root.owasp_profile.first_contribution_at.timestamp() + if hasattr(root, "owasp_profile") and root.owasp_profile.first_contribution_at + else None + ) - @strawberry.field - def is_owasp_board_member(self) -> bool: + @strawberry_django.field(select_related=["owasp_profile"]) + def is_owasp_board_member(self, root: User) -> bool: """Resolve if member is currently on OWASP Board of Directors.""" - if hasattr(self, "owasp_profile"): - return self.owasp_profile.is_owasp_board_member - return False + return ( + root.owasp_profile.is_owasp_board_member if hasattr(root, "owasp_profile") else False + ) - @strawberry.field - def is_former_owasp_staff(self) -> bool: + @strawberry_django.field(select_related=["owasp_profile"]) + def is_former_owasp_staff(self, root: User) -> bool: """Resolve if member is a former OWASP staff member.""" - if hasattr(self, "owasp_profile"): - return self.owasp_profile.is_former_owasp_staff - return False + return ( + root.owasp_profile.is_former_owasp_staff if hasattr(root, "owasp_profile") else False + ) - @strawberry.field - def is_gsoc_mentor(self) -> bool: + @strawberry_django.field(select_related=["owasp_profile"]) + def is_gsoc_mentor(self, root: User) -> bool: """Resolve if member is a Google Summer of Code mentor.""" - if hasattr(self, "owasp_profile"): - return self.owasp_profile.is_gsoc_mentor - return False + return root.owasp_profile.is_gsoc_mentor if hasattr(root, "owasp_profile") else False - @strawberry.field - def issues_count(self) -> int: + @strawberry_django.field + def issues_count(self, root: User) -> int: """Resolve issues count.""" - return self.idx_issues_count + return root.idx_issues_count - @strawberry.field - def linkedin_page_id(self) -> str: + @strawberry_django.field(select_related=["owasp_profile"]) + def linkedin_page_id(self, root: User) -> str: """Resolve LinkedIn page ID.""" - if hasattr(self, "owasp_profile") and self.owasp_profile.linkedin_page_id: - return self.owasp_profile.linkedin_page_id - return "" + return ( + root.owasp_profile.linkedin_page_id + if hasattr(root, "owasp_profile") and root.owasp_profile.linkedin_page_id + else "" + ) - @strawberry.field - def releases_count(self) -> int: + @strawberry_django.field + def releases_count(self, root: User) -> int: """Resolve releases count.""" - return self.idx_releases_count + return root.idx_releases_count - @strawberry.field - def updated_at(self) -> float: + @strawberry_django.field + def updated_at(self, root: User) -> float: """Resolve updated at.""" - return self.idx_updated_at + return root.idx_updated_at - @strawberry.field - def url(self) -> str: + @strawberry_django.field + def url(self, root: User) -> str: """Resolve URL.""" - return self.url + return root.url diff --git a/backend/apps/github/api/internal/queries/issue.py b/backend/apps/github/api/internal/queries/issue.py index 87cb288401..c1c077c2b3 100644 --- a/backend/apps/github/api/internal/queries/issue.py +++ b/backend/apps/github/api/internal/queries/issue.py @@ -1,17 +1,21 @@ """GraphQL queries for handling GitHub issues.""" import strawberry -from django.db.models import OuterRef, Subquery +import strawberry_django +from django.db.models import F, Window +from django.db.models.functions import Rank from apps.github.api.internal.nodes.issue import IssueNode from apps.github.models.issue import Issue +MAX_LIMIT = 1000 + @strawberry.type class IssueQuery: """GraphQL query class for retrieving GitHub issues.""" - @strawberry.field + @strawberry_django.field def recent_issues( self, *, @@ -23,7 +27,7 @@ def recent_issues( """Resolve recent issues with optional filtering. Args: - distinct (bool): Whether to return unique issues per author and repository. + distinct (bool): Whether to return unique issues per author. limit (int): Maximum number of issues to return. login (str, optional): Filter issues by a specific author's login. organization (str, optional): Filter issues by a specific organization's login. @@ -32,34 +36,29 @@ def recent_issues( list[IssueNode]: List of issue nodes. """ - queryset = Issue.objects.select_related( - "author", - "repository", - "repository__organization", - ).order_by( + queryset = Issue.objects.order_by( "-created_at", ) + filters = {} if login: - queryset = queryset.filter( - author__login=login, - ) - + filters["author__login"] = login if organization: - queryset = queryset.filter( - repository__organization__login=organization, - ) + filters["repository__organization__login"] = organization + + queryset = queryset.filter(**filters) if distinct: - latest_issue_per_author = ( - queryset.filter(author_id=OuterRef("author_id")) + queryset = ( + queryset.annotate( + rank=Window( + expression=Rank(), + partition_by=[F("author_id")], + order_by=F("created_at").desc(), + ) + ) + .filter(rank=1) .order_by("-created_at") - .values("id")[:1] - ) - queryset = queryset.filter( - id__in=Subquery(latest_issue_per_author), - ).order_by( - "-created_at", ) - return queryset[:limit] + return queryset[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] diff --git a/backend/apps/github/api/internal/queries/milestone.py b/backend/apps/github/api/internal/queries/milestone.py index 8c95ff55cf..bb87fab3a4 100644 --- a/backend/apps/github/api/internal/queries/milestone.py +++ b/backend/apps/github/api/internal/queries/milestone.py @@ -1,18 +1,31 @@ """Github Milestone Queries.""" +import enum + import strawberry -from django.core.exceptions import ValidationError +import strawberry_django from django.db.models import OuterRef, Subquery 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 +MAX_LIMIT = 1000 + + +@strawberry.enum +class MilestoneStateEnum(str, enum.Enum): + """Milestone state filter options.""" + + CLOSED = GenericIssueModel.State.CLOSED.value + OPEN = GenericIssueModel.State.OPEN.value + @strawberry.type class MilestoneQuery: """Github Milestone Queries.""" - @strawberry.field + @strawberry_django.field def recent_milestones( self, *, @@ -20,7 +33,7 @@ def recent_milestones( limit: int = 5, login: str | None = None, organization: str | None = None, - state: str = "open", + state: MilestoneStateEnum | None = None, ) -> list[MilestoneNode]: """Resolve milestones. @@ -29,32 +42,20 @@ def recent_milestones( limit (int): The maximum number of milestones to return. login (str, optional): The GitHub username to filter milestones. organization (str, optional): The GitHub organization to filter milestones. - state (str, optional): The state of the milestones to return. + state (MilestoneStateEnum, optional): The state filter. Returns all if not provided. Returns: list[MilestoneNode]: A list of milestones. """ - match state.lower(): - case "open": + match state: + case MilestoneStateEnum.OPEN: milestones = Milestone.open_milestones.all() - case "closed": + case MilestoneStateEnum.CLOSED: milestones = Milestone.closed_milestones.all() - case "all": - milestones = Milestone.objects.all() case _: - message = f"Invalid state: {state}. Valid states are 'open', 'closed', or 'all'." - raise ValidationError(message) - - milestones = milestones.select_related( - "author", - "repository", - "repository__organization", - ).prefetch_related( - "issues", - "labels", - "pull_requests", - ) + milestones = Milestone.objects.all() + if login: milestones = milestones.filter( author__login=login, @@ -75,6 +76,8 @@ def recent_milestones( id__in=Subquery(latest_milestone_per_author), ) - return milestones.order_by( - "-created_at", - )[:limit] + return ( + milestones.order_by("-created_at")[:limit] + if (limit := min(limit, MAX_LIMIT)) > 0 + else [] + ) diff --git a/backend/apps/github/api/internal/queries/pull_request.py b/backend/apps/github/api/internal/queries/pull_request.py index 4649a55a91..d001331003 100644 --- a/backend/apps/github/api/internal/queries/pull_request.py +++ b/backend/apps/github/api/internal/queries/pull_request.py @@ -1,18 +1,22 @@ """Github pull requests GraphQL queries.""" import strawberry -from django.db.models import OuterRef, Subquery +import strawberry_django +from django.db.models import F, Window +from django.db.models.functions import Rank 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 +MAX_LIMIT = 1000 + @strawberry.type class PullRequestQuery: """Pull request queries.""" - @strawberry.field + @strawberry_django.field def recent_pull_requests( self, *, @@ -26,7 +30,7 @@ def recent_pull_requests( """Resolve recent pull requests. Args: - distinct (bool): Whether to return unique pull requests per author and repository. + distinct (bool): Whether to return unique pull requests per author. limit (int): Maximum number of pull requests to return. login (str, optional): Filter pull requests by a specific author's login. organization (str, optional): Filter pull requests by a specific organization's login. @@ -38,52 +42,44 @@ def recent_pull_requests( filtered list of pull requests. """ - queryset = ( - PullRequest.objects.select_related( - "author", - "repository", - "repository__organization", - ) - .exclude( - author__is_bot=True, - ) - .order_by( - "-created_at", - ) + queryset = PullRequest.objects.exclude( + author__is_bot=True, + ).order_by( + "-created_at", ) + filters = {} if login: - queryset = queryset.filter(author__login=login) + filters["author__login"] = login if organization: - queryset = queryset.filter( - repository__organization__login=organization, - ) + filters["repository__organization__login"] = organization + + queryset = queryset.filter(**filters) if project: - queryset = queryset.filter( - repository_id__in=Project.objects.filter(key__iexact=f"www-project-{project}") - .first() - .repositories.values_list("id", flat=True) - ) + project_instance = Project.objects.filter(key__iexact=f"www-project-{project}").first() + if project_instance: + queryset = queryset.filter( + repository_id__in=project_instance.repositories.values_list("id", flat=True) + ) + else: + queryset = queryset.none() if repository: queryset = queryset.filter(repository__key__iexact=repository) if distinct: - latest_pull_request_per_author = ( - queryset.filter( - author_id=OuterRef("author_id"), + queryset = ( + queryset.annotate( + rank=Window( + expression=Rank(), + partition_by=[F("author_id")], + order_by=F("created_at").desc(), + ) ) - .order_by( - "-created_at", - ) - .values("id")[:1] - ) - queryset = queryset.filter( - id__in=Subquery(latest_pull_request_per_author), - ).order_by( - "-created_at", + .filter(rank=1) + .order_by("-created_at") ) - return queryset[:limit] + return queryset[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] diff --git a/backend/apps/github/api/internal/queries/release.py b/backend/apps/github/api/internal/queries/release.py index f9e3a60b6c..928d78a9a8 100644 --- a/backend/apps/github/api/internal/queries/release.py +++ b/backend/apps/github/api/internal/queries/release.py @@ -1,17 +1,21 @@ """GraphQL queries for handling OWASP releases.""" import strawberry -from django.db.models import OuterRef, Subquery +import strawberry_django +from django.db.models import F, Window +from django.db.models.functions import Rank from apps.github.api.internal.nodes.release import ReleaseNode from apps.github.models.release import Release +MAX_LIMIT = 1000 + @strawberry.type class ReleaseQuery: """GraphQL query class for retrieving recent GitHub releases.""" - @strawberry.field + @strawberry_django.field def recent_releases( self, *, @@ -23,7 +27,7 @@ def recent_releases( """Resolve recent releases with optional distinct filtering. Args: - distinct (bool): Whether to return unique releases per author and repository. + distinct (bool): Whether to return unique releases per author. limit (int): Maximum number of releases to return. login (str, optional): Filter releases by a specific author's login. organization (str, optional): Filter releases by a specific organization's login. @@ -39,11 +43,7 @@ def recent_releases( ).order_by("-published_at") if login: - queryset = queryset.select_related( - "author", - "repository", - "repository__organization", - ).filter( + queryset = queryset.filter( author__login=login, ) @@ -53,15 +53,16 @@ def recent_releases( ) if distinct: - latest_release_per_author = ( - queryset.filter(author_id=OuterRef("author_id")) + queryset = ( + queryset.annotate( + rank=Window( + expression=Rank(), + partition_by=[F("author_id")], + order_by=F("published_at").desc(), + ) + ) + .filter(rank=1) .order_by("-published_at") - .values("id")[:1] - ) - queryset = queryset.filter( - id__in=Subquery(latest_release_per_author), - ).order_by( - "-published_at", ) - return queryset[:limit] + return queryset[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] diff --git a/backend/apps/github/api/internal/queries/repository.py b/backend/apps/github/api/internal/queries/repository.py index b7bf1a59c7..70a0a033b8 100644 --- a/backend/apps/github/api/internal/queries/repository.py +++ b/backend/apps/github/api/internal/queries/repository.py @@ -1,16 +1,19 @@ """OWASP repository GraphQL queries.""" import strawberry +import strawberry_django from apps.github.api.internal.nodes.repository import RepositoryNode from apps.github.models.repository import Repository +MAX_LIMIT = 1000 + @strawberry.type class RepositoryQuery: """Repository queries.""" - @strawberry.field + @strawberry_django.field def repository( self, organization_key: str, @@ -34,7 +37,7 @@ def repository( except Repository.DoesNotExist: return None - @strawberry.field + @strawberry_django.field def repositories( self, organization: str, @@ -52,11 +55,11 @@ def repositories( """ return ( - Repository.objects.select_related( - "organization", - ) - .filter( - organization__login__iexact=organization, + ( + Repository.objects.filter( + organization__login__iexact=organization, + ).order_by("-stars_count")[:limit] ) - .order_by("-stars_count")[:limit] + if (limit := min(limit, MAX_LIMIT)) > 0 + else [] ) diff --git a/backend/apps/github/api/internal/queries/repository_contributor.py b/backend/apps/github/api/internal/queries/repository_contributor.py index fd2716c8ba..9ae9c8f0b5 100644 --- a/backend/apps/github/api/internal/queries/repository_contributor.py +++ b/backend/apps/github/api/internal/queries/repository_contributor.py @@ -1,16 +1,19 @@ """OWASP repository contributor GraphQL queries.""" import strawberry +import strawberry_django from apps.github.api.internal.nodes.repository_contributor import RepositoryContributorNode from apps.github.models.repository_contributor import RepositoryContributor +MAX_LIMIT = 100 + @strawberry.type class RepositoryContributorQuery: """Repository contributor queries.""" - @strawberry.field + @strawberry_django.field def top_contributors( self, *, @@ -39,6 +42,9 @@ def top_contributors( list: List of top contributors with their details. """ + if (limit := min(limit, MAX_LIMIT)) <= 0: + return [] + top_contributors = RepositoryContributor.get_top_contributors( chapter=chapter, committee=committee, diff --git a/backend/apps/github/api/internal/queries/user.py b/backend/apps/github/api/internal/queries/user.py index 199c33aa90..d99df0de43 100644 --- a/backend/apps/github/api/internal/queries/user.py +++ b/backend/apps/github/api/internal/queries/user.py @@ -1,6 +1,7 @@ """OWASP user GraphQL queries.""" import strawberry +import strawberry_django from apps.github.api.internal.nodes.repository import RepositoryNode from apps.github.api.internal.nodes.user import UserNode @@ -12,7 +13,7 @@ class UserQuery: """User queries.""" - @strawberry.field + @strawberry_django.field def top_contributed_repositories( self, login: str, @@ -36,7 +37,7 @@ def top_contributed_repositories( .order_by("-contributions_count") ] - @strawberry.field + @strawberry_django.field def user( self, login: str, diff --git a/backend/apps/github/migrations/0041_milestone_github_milestone_created_at_and_more.py b/backend/apps/github/migrations/0041_milestone_github_milestone_created_at_and_more.py new file mode 100644 index 0000000000..8837e1a27e --- /dev/null +++ b/backend/apps/github/migrations/0041_milestone_github_milestone_created_at_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0 on 2026-01-11 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0040_merge_20251117_0136"), + ] + + operations = [ + migrations.AddIndex( + model_name="milestone", + index=models.Index(fields=["-created_at"], name="github_milestone_created_at"), + ), + migrations.AddIndex( + model_name="milestone", + index=models.Index(fields=["-updated_at"], name="github_milestone_updated_at"), + ), + ] diff --git a/backend/apps/github/models/milestone.py b/backend/apps/github/models/milestone.py index 77ffa1e3c4..8d88552e33 100644 --- a/backend/apps/github/models/milestone.py +++ b/backend/apps/github/models/milestone.py @@ -19,6 +19,11 @@ class Meta: verbose_name_plural = "Milestones" ordering = ["-updated_at", "-state"] + indexes = [ + models.Index(fields=["-created_at"], name="github_milestone_created_at"), + models.Index(fields=["-updated_at"], name="github_milestone_updated_at"), + ] + open_issues_count = models.PositiveIntegerField(default=0) closed_issues_count = models.PositiveIntegerField(default=0) due_on = models.DateTimeField(blank=True, null=True) diff --git a/backend/apps/github/models/organization.py b/backend/apps/github/models/organization.py index b1fadf90d6..a062cf6061 100644 --- a/backend/apps/github/models/organization.py +++ b/backend/apps/github/models/organization.py @@ -54,7 +54,9 @@ def related_projects(self) -> QuerySet: """Return organization related projects.""" return ( apps.get_model("owasp", "Project") # Dynamic import. - .objects.filter( + .objects.select_related("owasp_repository") + .prefetch_related("organizations", "owners", "repositories") + .filter( repositories__in=self.repositories.all(), ) .distinct() diff --git a/backend/apps/github/models/repository.py b/backend/apps/github/models/repository.py index ead41d6743..8648d49666 100644 --- a/backend/apps/github/models/repository.py +++ b/backend/apps/github/models/repository.py @@ -11,7 +11,6 @@ from apps.common.models import TimestampedModel from apps.github.constants import OWASP_LOGIN from apps.github.models.common import NodeModel -from apps.github.models.milestone import Milestone from apps.github.models.mixins import RepositoryIndexMixin from apps.github.utils import ( check_funding_policy_compliance, @@ -175,9 +174,7 @@ def published_releases(self): @property def recent_milestones(self): """Repository recent milestones.""" - return Milestone.objects.filter( - repository=self, - ).order_by("-created_at") + return self.milestones.order_by("-created_at") if self.pk else [] @property def top_languages(self) -> list[str]: diff --git a/backend/apps/github/models/repository_contributor.py b/backend/apps/github/models/repository_contributor.py index 982eb2c190..fd920a8ff0 100644 --- a/backend/apps/github/models/repository_contributor.py +++ b/backend/apps/github/models/repository_contributor.py @@ -179,21 +179,26 @@ def get_top_contributors( # Aggregate total contributions for users. top_contributors = ( - queryset.values( - "user__avatar_url", - "user__login", - "user__name", + ( + queryset.values( + "user__avatar_url", + "user__login", + "user__name", + ) + .annotate( + total_contributions=Sum("contributions_count"), + ) + .order_by("-total_contributions")[:limit] ) - .annotate( - total_contributions=Sum("contributions_count"), - ) - .order_by("-total_contributions")[:limit] + if limit > 0 + else [] ) return [ { "avatar_url": tc["user__avatar_url"], "contributions_count": tc["total_contributions"], + "id": tc["user__login"], "login": tc["user__login"], "name": tc["user__name"], } diff --git a/backend/apps/mentorship/api/internal/queries/mentorship.py b/backend/apps/mentorship/api/internal/queries/mentorship.py index cab13ed8d5..a0ae2df5eb 100644 --- a/backend/apps/mentorship/api/internal/queries/mentorship.py +++ b/backend/apps/mentorship/api/internal/queries/mentorship.py @@ -2,10 +2,10 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, cast import strawberry -from django.core.exceptions import ObjectDoesNotExist from django.db.models import Prefetch from apps.github.api.internal.nodes.issue import IssueNode @@ -20,6 +20,8 @@ if TYPE_CHECKING: from apps.github.api.internal.nodes.issue import IssueNode +logger = logging.getLogger(__name__) + @strawberry.type class UserRolesResult: @@ -48,7 +50,9 @@ def is_mentor(self, login: str) -> bool: return Mentor.objects.filter(github_user=github_user).exists() @strawberry.field - def get_mentee_details(self, program_key: str, module_key: str, mentee_key: str) -> MenteeNode: + def get_mentee_details( + self, program_key: str, module_key: str, mentee_key: str + ) -> MenteeNode | None: """Get detailed information about a mentee in a specific module.""" try: module = Module.objects.only("id").get(key=module_key, program__key=program_key) @@ -64,7 +68,8 @@ def get_mentee_details(self, program_key: str, module_key: str, mentee_key: str) if not is_enrolled: message = f"Mentee {mentee_key} is not enrolled in module {module_key}" - raise ObjectDoesNotExist(message) + logger.warning(message) + return None return MenteeNode( id=cast("strawberry.ID", str(mentee.id)), @@ -79,7 +84,8 @@ def get_mentee_details(self, program_key: str, module_key: str, mentee_key: str) except (Module.DoesNotExist, GithubUser.DoesNotExist, Mentee.DoesNotExist) as e: message = f"Mentee details not found: {e}" - raise ObjectDoesNotExist(message) from e + logger.warning(message) + return None @strawberry.field def get_mentee_module_issues( @@ -101,7 +107,8 @@ def get_mentee_module_issues( if not is_enrolled: message = f"Mentee {mentee_key} is not enrolled in module {module_key}" - raise ObjectDoesNotExist(message) + logger.warning(message) + return [] issues_qs = ( module.issues.filter(assignees=github_user) @@ -121,4 +128,5 @@ def get_mentee_module_issues( except (Module.DoesNotExist, GithubUser.DoesNotExist, Mentee.DoesNotExist) as e: message = f"Mentee issues not found: {e}" - raise ObjectDoesNotExist(message) from e + logger.warning(message) + return [] diff --git a/backend/apps/mentorship/api/internal/queries/module.py b/backend/apps/mentorship/api/internal/queries/module.py index 8fec753752..aed931a37f 100644 --- a/backend/apps/mentorship/api/internal/queries/module.py +++ b/backend/apps/mentorship/api/internal/queries/module.py @@ -3,7 +3,6 @@ import logging import strawberry -from django.core.exceptions import ObjectDoesNotExist from apps.mentorship.api.internal.nodes.module import ModuleNode from apps.mentorship.models import Module @@ -36,7 +35,7 @@ def get_project_modules(self, project_key: str) -> list[ModuleNode]: ) @strawberry.field - def get_module(self, module_key: str, program_key: str) -> ModuleNode: + def get_module(self, module_key: str, program_key: str) -> ModuleNode | None: """Get a single module by its key within a specific program.""" try: return ( @@ -44,7 +43,7 @@ def get_module(self, module_key: str, program_key: str) -> ModuleNode: .prefetch_related("mentors__github_user") .get(key=module_key, program__key=program_key) ) - except Module.DoesNotExist as err: + except Module.DoesNotExist: msg = f"Module with key '{module_key}' under program '{program_key}' not found." logger.warning(msg, exc_info=True) - raise ObjectDoesNotExist(msg) from err + return None diff --git a/backend/apps/mentorship/api/internal/queries/program.py b/backend/apps/mentorship/api/internal/queries/program.py index 0abf8a263d..fb272e8165 100644 --- a/backend/apps/mentorship/api/internal/queries/program.py +++ b/backend/apps/mentorship/api/internal/queries/program.py @@ -3,7 +3,6 @@ import logging import strawberry -from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from apps.mentorship.api.internal.nodes.program import PaginatedPrograms, ProgramNode @@ -20,14 +19,14 @@ class ProgramQuery: """Program queries.""" @strawberry.field - def get_program(self, program_key: str) -> ProgramNode: + def get_program(self, program_key: str) -> ProgramNode | None: """Get a program by Key.""" try: program = Program.objects.prefetch_related("admins__github_user").get(key=program_key) - except Program.DoesNotExist as err: + except Program.DoesNotExist: msg = f"Program with key '{program_key}' not found." logger.warning(msg, exc_info=True) - raise ObjectDoesNotExist(msg) from err + return None return program diff --git a/backend/apps/owasp/api/internal/filters/project_health_metrics.py b/backend/apps/owasp/api/internal/filters/project_health_metrics.py index 2e7726cb49..2660c80601 100644 --- a/backend/apps/owasp/api/internal/filters/project_health_metrics.py +++ b/backend/apps/owasp/api/internal/filters/project_health_metrics.py @@ -17,6 +17,6 @@ class ProjectHealthMetricsFilter: @strawberry_django.filter_field # prefix is required for strawberry to work with nested filters # Q is the return type for the filter - def level(self, value: str, prefix: str): + def level(self, value: ProjectLevel, prefix: str): """Filter by project level.""" return Q(project__level=ProjectLevel(value)) if value else Q() diff --git a/backend/apps/owasp/api/internal/nodes/board_of_directors.py b/backend/apps/owasp/api/internal/nodes/board_of_directors.py index 71e9c35577..b12655f533 100644 --- a/backend/apps/owasp/api/internal/nodes/board_of_directors.py +++ b/backend/apps/owasp/api/internal/nodes/board_of_directors.py @@ -10,25 +10,25 @@ @strawberry_django.type( BoardOfDirectors, fields=[ - "year", "created_at", "updated_at", + "year", ], ) class BoardOfDirectorsNode(strawberry.relay.Node): """Board of Directors node.""" - @strawberry.field - def candidates(self) -> list[EntityMemberNode]: + @strawberry_django.field + def candidates(self, root: BoardOfDirectors) -> list[EntityMemberNode]: """Resolve board election candidates.""" - return self.get_candidates() # type: ignore[call-arg] + return root.get_candidates() # type: ignore[call-arg] - @strawberry.field - def members(self) -> list[EntityMemberNode]: + @strawberry_django.field + def members(self, root: BoardOfDirectors) -> list[EntityMemberNode]: """Resolve board members.""" - return self.get_members() # type: ignore[call-arg] + return root.get_members() # type: ignore[call-arg] - @strawberry.field - def owasp_url(self) -> str: + @strawberry_django.field + def owasp_url(self, root: BoardOfDirectors) -> str: """Resolve OWASP board election URL.""" - return self.owasp_url + return root.owasp_url diff --git a/backend/apps/owasp/api/internal/nodes/chapter.py b/backend/apps/owasp/api/internal/nodes/chapter.py index 6e5c29bfb4..82eb4a521d 100644 --- a/backend/apps/owasp/api/internal/nodes/chapter.py +++ b/backend/apps/owasp/api/internal/nodes/chapter.py @@ -33,31 +33,31 @@ class GeoLocationType: class ChapterNode(GenericEntityNode): """Chapter node.""" - @strawberry.field - def contribution_stats(self) -> strawberry.scalars.JSON | None: + @strawberry_django.field + def contribution_stats(self, root: Chapter) -> strawberry.scalars.JSON | None: """Resolve contribution stats with camelCase keys.""" - return deep_camelize(self.contribution_stats) + return deep_camelize(root.contribution_stats) - @strawberry.field - def created_at(self) -> float: + @strawberry_django.field + def created_at(self, root: Chapter) -> float: """Resolve created at.""" - return self.idx_created_at + return root.idx_created_at - @strawberry.field - def geo_location(self) -> GeoLocationType | None: + @strawberry_django.field + def geo_location(self, root: Chapter) -> GeoLocationType | None: """Resolve geographic location.""" return ( - GeoLocationType(lat=self.latitude, lng=self.longitude) - if self.latitude is not None and self.longitude is not None + GeoLocationType(lat=root.latitude, lng=root.longitude) + if root.latitude is not None and root.longitude is not None else None ) - @strawberry.field - def key(self) -> str: + @strawberry_django.field + def key(self, root: Chapter) -> str: """Resolve key.""" - return self.idx_key + return root.idx_key - @strawberry.field - def suggested_location(self) -> str | None: + @strawberry_django.field + def suggested_location(self, root: Chapter) -> str | None: """Resolve suggested location.""" - return self.idx_suggested_location + return root.idx_suggested_location diff --git a/backend/apps/owasp/api/internal/nodes/committee.py b/backend/apps/owasp/api/internal/nodes/committee.py index fd4b83431f..51165cf09e 100644 --- a/backend/apps/owasp/api/internal/nodes/committee.py +++ b/backend/apps/owasp/api/internal/nodes/committee.py @@ -1,6 +1,5 @@ """OWASP committee GraphQL node.""" -import strawberry import strawberry_django from apps.owasp.api.internal.nodes.common import GenericEntityNode @@ -11,32 +10,32 @@ class CommitteeNode(GenericEntityNode): """Committee node.""" - @strawberry.field - def contributors_count(self) -> int: + @strawberry_django.field + def contributors_count(self, root: Committee) -> int: """Resolve contributors count.""" - return self.owasp_repository.contributors_count + return root.owasp_repository.contributors_count if root.owasp_repository else 0 - @strawberry.field - def created_at(self) -> float: + @strawberry_django.field + def created_at(self, root: Committee) -> float | None: """Resolve created at.""" - return self.idx_created_at + return root.idx_created_at - @strawberry.field - def forks_count(self) -> int: + @strawberry_django.field + def forks_count(self, root: Committee) -> int: """Resolve forks count.""" - return self.owasp_repository.forks_count + return root.owasp_repository.forks_count if root.owasp_repository else 0 - @strawberry.field - def issues_count(self) -> int: + @strawberry_django.field + def issues_count(self, root: Committee) -> int: """Resolve issues count.""" - return self.owasp_repository.open_issues_count + return root.owasp_repository.open_issues_count if root.owasp_repository else 0 - @strawberry.field - def repositories_count(self) -> int: + @strawberry_django.field + def repositories_count(self, root: Committee) -> int: """Resolve repositories count.""" return 1 - @strawberry.field - def stars_count(self) -> int: + @strawberry_django.field + def stars_count(self, root: Committee) -> int: """Resolve stars count.""" - return self.owasp_repository.stars_count + return root.owasp_repository.stars_count if root.owasp_repository else 0 diff --git a/backend/apps/owasp/api/internal/nodes/common.py b/backend/apps/owasp/api/internal/nodes/common.py index a692265fad..2a631800f6 100644 --- a/backend/apps/owasp/api/internal/nodes/common.py +++ b/backend/apps/owasp/api/internal/nodes/common.py @@ -1,6 +1,7 @@ """OWASP common GraphQL node.""" import strawberry +import strawberry_django from apps.github.api.internal.nodes.repository_contributor import RepositoryContributorNode from apps.owasp.api.internal.nodes.entity_member import EntityMemberNode @@ -10,32 +11,32 @@ class GenericEntityNode(strawberry.relay.Node): """Base node class for OWASP entities with common fields and resolvers.""" - @strawberry.field - def entity_leaders(self) -> list[EntityMemberNode]: + @strawberry_django.field(prefetch_related=["entity_members__member"]) + def entity_leaders(self, root) -> list[EntityMemberNode]: """Resolve entity leaders.""" - return self.entity_leaders + return root.entity_leaders - @strawberry.field - def leaders(self) -> list[str]: + @strawberry_django.field + def leaders(self, root) -> list[str]: """Resolve leaders.""" - return self.idx_leaders + return root.idx_leaders - @strawberry.field - def related_urls(self) -> list[str]: + @strawberry_django.field + def related_urls(self, root) -> list[str]: """Resolve related URLs.""" - return self.idx_related_urls + return root.related_urls - @strawberry.field - def top_contributors(self) -> list[RepositoryContributorNode]: + @strawberry_django.field + def top_contributors(self, root) -> list[RepositoryContributorNode]: """Resolve top contributors.""" - return [RepositoryContributorNode(**tc) for tc in self.idx_top_contributors] + return [RepositoryContributorNode(**tc) for tc in root.idx_top_contributors] - @strawberry.field - def updated_at(self) -> float: + @strawberry_django.field + def updated_at(self, root) -> float: """Resolve updated at.""" - return self.idx_updated_at + return root.idx_updated_at - @strawberry.field - def url(self) -> str: + @strawberry_django.field + def url(self, root) -> str: """Resolve URL.""" - return self.idx_url + return root.idx_url diff --git a/backend/apps/owasp/api/internal/nodes/member_snapshot.py b/backend/apps/owasp/api/internal/nodes/member_snapshot.py index bb6a6701a2..2319d61ed6 100644 --- a/backend/apps/owasp/api/internal/nodes/member_snapshot.py +++ b/backend/apps/owasp/api/internal/nodes/member_snapshot.py @@ -23,32 +23,32 @@ class MemberSnapshotNode(strawberry.relay.Node): """Member snapshot node.""" - @strawberry.field - def commits_count(self) -> int: + @strawberry_django.field + def commits_count(self, root: MemberSnapshot) -> int: """Resolve commits count.""" - return self.commits_count + return root.commits_count - @strawberry.field - def github_user(self) -> UserNode: + @strawberry_django.field(select_related=["github_user"]) + def github_user(self, root: MemberSnapshot) -> UserNode: """Resolve GitHub user.""" - return self.github_user + return root.github_user - @strawberry.field - def issues_count(self) -> int: + @strawberry_django.field + def issues_count(self, root: MemberSnapshot) -> int: """Resolve issues count.""" - return self.issues_count + return root.issues_count - @strawberry.field - def pull_requests_count(self) -> int: + @strawberry_django.field + def pull_requests_count(self, root: MemberSnapshot) -> int: """Resolve pull requests count.""" - return self.pull_requests_count + return root.pull_requests_count - @strawberry.field - def messages_count(self) -> int: + @strawberry_django.field + def messages_count(self, root: MemberSnapshot) -> int: """Resolve Slack messages count.""" - return self.messages_count + return root.messages_count - @strawberry.field - def total_contributions(self) -> int: + @strawberry_django.field + def total_contributions(self, root: MemberSnapshot) -> int: """Resolve total contributions.""" - return self.total_contributions + return root.total_contributions diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py index f040a3e5ff..f5da84653f 100644 --- a/backend/apps/owasp/api/internal/nodes/project.py +++ b/backend/apps/owasp/api/internal/nodes/project.py @@ -9,17 +9,19 @@ from apps.github.api.internal.nodes.pull_request import PullRequestNode from apps.github.api.internal.nodes.release import ReleaseNode from apps.github.api.internal.nodes.repository import RepositoryNode +from apps.github.models.milestone import Milestone from apps.owasp.api.internal.nodes.common import GenericEntityNode from apps.owasp.api.internal.nodes.project_health_metrics import ( ProjectHealthMetricsNode, ) from apps.owasp.models.project import Project -from apps.owasp.models.project_health_metrics import ProjectHealthMetrics RECENT_ISSUES_LIMIT = 5 RECENT_RELEASES_LIMIT = 5 RECENT_PULL_REQUESTS_LIMIT = 5 +MAX_LIMIT = 1000 + @strawberry_django.type( Project, @@ -41,84 +43,92 @@ class ProjectNode(GenericEntityNode): """Project node.""" - @strawberry.field - def contribution_stats(self) -> strawberry.scalars.JSON | None: + @strawberry_django.field + def contribution_stats(self, root: Project) -> strawberry.scalars.JSON | None: """Resolve contribution stats with camelCase keys.""" - return deep_camelize(self.contribution_stats) + return deep_camelize(root.contribution_stats) - @strawberry.field - def health_metrics_list(self, limit: int = 30) -> list[ProjectHealthMetricsNode]: + @strawberry_django.field(prefetch_related=["health_metrics"]) + def health_metrics_list( + self, root: Project, limit: int = 30 + ) -> list[ProjectHealthMetricsNode]: """Resolve project health metrics.""" - return ProjectHealthMetrics.objects.filter( - project=self, - ).order_by( - "nest_created_at", - )[:limit] - - @strawberry.field - def health_metrics_latest(self) -> ProjectHealthMetricsNode | None: - """Resolve latest project health metrics.""" return ( - ProjectHealthMetrics.get_latest_health_metrics() - .filter( - project=self, - ) - .first() + root.health_metrics.order_by("nest_created_at")[:limit] + if (limit := min(limit, MAX_LIMIT)) > 0 + else [] ) - @strawberry.field - def issues_count(self) -> int: + @strawberry_django.field(prefetch_related=["health_metrics"]) + def health_metrics_latest(self, root: Project) -> ProjectHealthMetricsNode | None: + """Resolve latest project health metrics.""" + return root.health_metrics.order_by("-nest_created_at").first() + + @strawberry_django.field + def issues_count(self, root: Project) -> int: """Resolve issues count.""" - return self.idx_issues_count + return root.idx_issues_count - @strawberry.field - def key(self) -> str: + @strawberry_django.field + def key(self, root: Project) -> str: """Resolve key.""" - return self.idx_key + return root.idx_key - @strawberry.field - def languages(self) -> list[str]: + @strawberry_django.field + def languages(self, root: Project) -> list[str]: """Resolve languages.""" - return self.idx_languages + return root.idx_languages - @strawberry.field - def recent_issues(self) -> list[IssueNode]: + @strawberry_django.field + def recent_issues(self, root: Project) -> list[IssueNode]: """Resolve recent issues.""" - return self.issues.select_related("author").order_by("-created_at")[:RECENT_ISSUES_LIMIT] + return root.issues.order_by("-created_at")[:RECENT_ISSUES_LIMIT] - @strawberry.field - def recent_milestones(self, limit: int = 5) -> list[MilestoneNode]: + @strawberry_django.field + def recent_milestones(self, root: Project, limit: int = 5) -> list[MilestoneNode]: """Resolve recent milestones.""" - return self.recent_milestones.select_related("author").order_by("-created_at")[:limit] + return ( + Milestone.objects.filter( + repository__in=root.repositories.all(), + ) + .select_related( + "repository__organization", + "author__owasp_profile", + ) + .prefetch_related( + "labels", + ) + .order_by("-created_at")[:limit] + if (limit := min(limit, MAX_LIMIT)) > 0 + else [] + ) - @strawberry.field - def recent_pull_requests(self) -> list[PullRequestNode]: + @strawberry_django.field + def recent_pull_requests(self, root: Project) -> list[PullRequestNode]: """Resolve recent pull requests.""" - return self.pull_requests.select_related("author").order_by("-created_at")[ - :RECENT_PULL_REQUESTS_LIMIT - ] + return root.pull_requests.order_by("-created_at")[:RECENT_PULL_REQUESTS_LIMIT] - @strawberry.field - def recent_releases(self) -> list[ReleaseNode]: + @strawberry_django.field + def recent_releases(self, root: Project) -> list[ReleaseNode]: """Resolve recent releases.""" - return self.published_releases.order_by("-published_at")[:RECENT_RELEASES_LIMIT] + return root.published_releases.order_by("-published_at")[:RECENT_RELEASES_LIMIT] - @strawberry.field - def repositories(self) -> list[RepositoryNode]: + @strawberry_django.field(prefetch_related=["repositories"]) + def repositories(self, root: Project) -> list[RepositoryNode]: """Resolve repositories.""" - return self.repositories.filter( + return root.repositories.filter( organization__isnull=False, ).order_by( "-pushed_at", "-updated_at", ) - @strawberry.field - def repositories_count(self) -> int: + @strawberry_django.field + def repositories_count(self, root: Project) -> int: """Resolve repositories count.""" - return self.idx_repositories_count + return root.idx_repositories_count - @strawberry.field - def topics(self) -> list[str]: + @strawberry_django.field + def topics(self, root: Project) -> list[str]: """Resolve topics.""" - return self.idx_topics + return root.idx_topics diff --git a/backend/apps/owasp/api/internal/nodes/project_health_metrics.py b/backend/apps/owasp/api/internal/nodes/project_health_metrics.py index ce29c9dfcc..067b79ae79 100644 --- a/backend/apps/owasp/api/internal/nodes/project_health_metrics.py +++ b/backend/apps/owasp/api/internal/nodes/project_health_metrics.py @@ -31,67 +31,67 @@ class ProjectHealthMetricsNode(strawberry.relay.Node): """Project health metrics node.""" - @strawberry.field - def age_days(self) -> int: + @strawberry_django.field + def age_days(self, root: ProjectHealthMetrics) -> int: """Resolve project age in days.""" - return self.age_days + return root.age_days - @strawberry.field - def age_days_requirement(self) -> int: + @strawberry_django.field + def age_days_requirement(self, root: ProjectHealthMetrics) -> int: """Resolve project age requirement in days.""" - return self.age_days_requirement + return root.age_days_requirement - @strawberry.field - def created_at(self) -> datetime: + @strawberry_django.field + def created_at(self, root: ProjectHealthMetrics) -> datetime: """Resolve metrics creation date.""" - return self.nest_created_at + return root.nest_created_at - @strawberry.field - def last_commit_days(self) -> int: + @strawberry_django.field + def last_commit_days(self, root: ProjectHealthMetrics) -> int: """Resolve last commit age in days.""" - return self.last_commit_days + return root.last_commit_days - @strawberry.field - def last_commit_days_requirement(self) -> int: + @strawberry_django.field + def last_commit_days_requirement(self, root: ProjectHealthMetrics) -> int: """Resolve last commit age requirement in days.""" - return self.last_commit_days_requirement + return root.last_commit_days_requirement - @strawberry.field - def last_pull_request_days(self) -> int: + @strawberry_django.field + def last_pull_request_days(self, root: ProjectHealthMetrics) -> int: """Resolve last pull request age in days.""" - return self.last_pull_request_days + return root.last_pull_request_days - @strawberry.field - def last_pull_request_days_requirement(self) -> int: + @strawberry_django.field + def last_pull_request_days_requirement(self, root: ProjectHealthMetrics) -> int: """Resolve last pull request age requirement in days.""" - return self.last_pull_request_days_requirement + return root.last_pull_request_days_requirement - @strawberry.field - def last_release_days(self) -> int: + @strawberry_django.field + def last_release_days(self, root: ProjectHealthMetrics) -> int: """Resolve last release age in days.""" - return self.last_release_days + return root.last_release_days - @strawberry.field - def last_release_days_requirement(self) -> int: + @strawberry_django.field + def last_release_days_requirement(self, root: ProjectHealthMetrics) -> int: """Resolve last release age requirement in days.""" - return self.last_release_days_requirement + return root.last_release_days_requirement - @strawberry.field - def project_key(self) -> str: + @strawberry_django.field(select_related=["project"]) + def project_key(self, root: ProjectHealthMetrics) -> str: """Resolve project key.""" - return self.project.nest_key + return root.project.nest_key - @strawberry.field - def project_name(self) -> str: + @strawberry_django.field(select_related=["project"]) + def project_name(self, root: ProjectHealthMetrics) -> str: """Resolve project name.""" - return self.project.name + return root.project.name - @strawberry.field - def owasp_page_last_update_days(self) -> int: + @strawberry_django.field + def owasp_page_last_update_days(self, root: ProjectHealthMetrics) -> int: """Resolve OWASP page last update age in days.""" - return self.owasp_page_last_update_days + return root.owasp_page_last_update_days - @strawberry.field - def owasp_page_last_update_days_requirement(self) -> int: + @strawberry_django.field + def owasp_page_last_update_days_requirement(self, root: ProjectHealthMetrics) -> int: """Resolve OWASP page last update age requirement in days.""" - return self.owasp_page_last_update_days_requirement + return root.owasp_page_last_update_days_requirement diff --git a/backend/apps/owasp/api/internal/nodes/project_health_stats.py b/backend/apps/owasp/api/internal/nodes/project_health_stats.py index 295d6be276..c005128ebd 100644 --- a/backend/apps/owasp/api/internal/nodes/project_health_stats.py +++ b/backend/apps/owasp/api/internal/nodes/project_health_stats.py @@ -7,7 +7,7 @@ class ProjectHealthStatsNode: """Node representing overall health stats of OWASP projects.""" - average_score: float + average_score: float | None monthly_overall_scores: list[float] monthly_overall_scores_months: list[int] projects_count_healthy: int diff --git a/backend/apps/owasp/api/internal/nodes/snapshot.py b/backend/apps/owasp/api/internal/nodes/snapshot.py index 0e67f09509..4e45ab89eb 100644 --- a/backend/apps/owasp/api/internal/nodes/snapshot.py +++ b/backend/apps/owasp/api/internal/nodes/snapshot.py @@ -7,7 +7,6 @@ from apps.github.api.internal.nodes.release import ReleaseNode from apps.github.api.internal.nodes.user import UserNode from apps.owasp.api.internal.nodes.chapter import ChapterNode -from apps.owasp.api.internal.nodes.common import GenericEntityNode from apps.owasp.api.internal.nodes.project import ProjectNode from apps.owasp.models.snapshot import Snapshot @@ -23,35 +22,32 @@ "title", ], ) -class SnapshotNode(GenericEntityNode): +class SnapshotNode(strawberry.relay.Node): """Snapshot node.""" - @strawberry.field - def key(self) -> str: - """Resolve key.""" - return self.key + new_chapters: list[ChapterNode] = strawberry_django.field() - @strawberry.field - def new_chapters(self) -> list[ChapterNode]: - """Resolve new chapters.""" - return self.new_chapters.all() + @strawberry_django.field + def key(self, root: Snapshot) -> str: + """Resolve key.""" + return root.key - @strawberry.field - def new_issues(self) -> list[IssueNode]: + @strawberry_django.field(prefetch_related=["new_issues"]) + def new_issues(self, root: Snapshot) -> list[IssueNode]: """Resolve new issues.""" - return self.new_issues.order_by("-created_at")[:RECENT_ISSUES_LIMIT] + return root.new_issues.order_by("-created_at")[:RECENT_ISSUES_LIMIT] - @strawberry.field - def new_projects(self) -> list[ProjectNode]: + @strawberry_django.field(prefetch_related=["new_projects"]) + def new_projects(self, root: Snapshot) -> list[ProjectNode]: """Resolve new projects.""" - return self.new_projects.order_by("-created_at") + return root.new_projects.order_by("-created_at") - @strawberry.field - def new_releases(self) -> list[ReleaseNode]: + @strawberry_django.field(prefetch_related=["new_releases"]) + def new_releases(self, root: Snapshot) -> list[ReleaseNode]: """Resolve new releases.""" - return self.new_releases.order_by("-published_at") + return root.new_releases.order_by("-published_at") - @strawberry.field - def new_users(self) -> list[UserNode]: + @strawberry_django.field(prefetch_related=["new_users"]) + def new_users(self, root: Snapshot) -> list[UserNode]: """Resolve new users.""" - return self.new_users.order_by("-created_at") + return root.new_users.order_by("-created_at") diff --git a/backend/apps/owasp/api/internal/permissions/project_health_metrics.py b/backend/apps/owasp/api/internal/permissions/project_health_metrics.py index aa75f689ec..9ad156ecac 100644 --- a/backend/apps/owasp/api/internal/permissions/project_health_metrics.py +++ b/backend/apps/owasp/api/internal/permissions/project_health_metrics.py @@ -1,5 +1,6 @@ """Strawberry Permission Classes for Project Health Metrics.""" +from django.conf import settings from strawberry.permission import BasePermission @@ -11,7 +12,11 @@ class HasDashboardAccess(BasePermission): def has_permission(self, source, info, **kwargs) -> bool: """Check if the user has dashboard access.""" return ( - (user := info.context.request.user) - and user.is_authenticated - and user.github_user.is_owasp_staff + True + if settings.IS_E2E_ENVIRONMENT or settings.IS_FUZZ_ENVIRONMENT + else ( + (user := info.context.request.user) + and user.is_authenticated + and user.github_user.is_owasp_staff + ) ) 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 9445386a03..df611022c3 100644 --- a/backend/apps/owasp/api/internal/queries/board_of_directors.py +++ b/backend/apps/owasp/api/internal/queries/board_of_directors.py @@ -1,16 +1,19 @@ """OWASP Board of Directors GraphQL queries.""" import strawberry +import strawberry_django from apps.owasp.api.internal.nodes.board_of_directors import BoardOfDirectorsNode from apps.owasp.models.board_of_directors import BoardOfDirectors +MAX_LIMIT = 1000 + @strawberry.type class BoardOfDirectorsQuery: """GraphQL queries for Board of Directors model.""" - @strawberry.field + @strawberry_django.field def board_of_directors(self, year: int) -> BoardOfDirectorsNode | None: """Resolve Board of Directors by year. @@ -26,7 +29,7 @@ def board_of_directors(self, year: int) -> BoardOfDirectorsNode | None: except BoardOfDirectors.DoesNotExist: return None - @strawberry.field + @strawberry_django.field def boards_of_directors(self, limit: int = 10) -> list[BoardOfDirectorsNode]: """Resolve multiple Board of Directors instances. @@ -37,4 +40,8 @@ def boards_of_directors(self, limit: int = 10) -> list[BoardOfDirectorsNode]: List of BoardOfDirectorsNode objects. """ - return BoardOfDirectors.objects.order_by("-year")[:limit] + return ( + BoardOfDirectors.objects.order_by("-year")[:limit] + if (limit := min(limit, MAX_LIMIT)) > 0 + else [] + ) diff --git a/backend/apps/owasp/api/internal/queries/chapter.py b/backend/apps/owasp/api/internal/queries/chapter.py index 8abebb5b5f..64ecfd7f41 100644 --- a/backend/apps/owasp/api/internal/queries/chapter.py +++ b/backend/apps/owasp/api/internal/queries/chapter.py @@ -1,16 +1,19 @@ """OWASP chapter GraphQL queries.""" import strawberry +import strawberry_django from apps.owasp.api.internal.nodes.chapter import ChapterNode from apps.owasp.models.chapter import Chapter +MAX_LIMIT = 1000 + @strawberry.type class ChapterQuery: """Chapter queries.""" - @strawberry.field + @strawberry_django.field def chapter(self, key: str) -> ChapterNode | None: """Resolve chapter.""" try: @@ -18,7 +21,11 @@ def chapter(self, key: str) -> ChapterNode | None: except Chapter.DoesNotExist: return None - @strawberry.field + @strawberry_django.field def recent_chapters(self, limit: int = 8) -> list[ChapterNode]: """Resolve recent chapters.""" - return Chapter.active_chapters.order_by("-created_at")[:limit] + return ( + Chapter.active_chapters.order_by("-created_at")[:limit] + if (limit := min(limit, MAX_LIMIT)) > 0 + else [] + ) diff --git a/backend/apps/owasp/api/internal/queries/committee.py b/backend/apps/owasp/api/internal/queries/committee.py index 4cbdb7f3bd..fb7bf37ba1 100644 --- a/backend/apps/owasp/api/internal/queries/committee.py +++ b/backend/apps/owasp/api/internal/queries/committee.py @@ -1,6 +1,7 @@ """OWASP committee GraphQL queries.""" import strawberry +import strawberry_django from apps.owasp.api.internal.nodes.committee import CommitteeNode from apps.owasp.models.committee import Committee @@ -10,7 +11,7 @@ class CommitteeQuery: """Committee queries.""" - @strawberry.field + @strawberry_django.field def committee(self, key: str) -> CommitteeNode | None: """Resolve committee by key. diff --git a/backend/apps/owasp/api/internal/queries/event.py b/backend/apps/owasp/api/internal/queries/event.py index b08973201a..68dad6941a 100644 --- a/backend/apps/owasp/api/internal/queries/event.py +++ b/backend/apps/owasp/api/internal/queries/event.py @@ -1,16 +1,19 @@ """OWASP event GraphQL queries.""" import strawberry +import strawberry_django from apps.owasp.api.internal.nodes.event import EventNode from apps.owasp.models.event import Event +MAX_LIMIT = 1000 + @strawberry.type class EventQuery: """Event queries.""" - @strawberry.field + @strawberry_django.field def upcoming_events(self, limit: int = 6) -> list[EventNode]: """Resolve upcoming events.""" - return Event.upcoming_events()[:limit] + return Event.upcoming_events()[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] diff --git a/backend/apps/owasp/api/internal/queries/member_snapshot.py b/backend/apps/owasp/api/internal/queries/member_snapshot.py index f48b03f01b..4aac59e04e 100644 --- a/backend/apps/owasp/api/internal/queries/member_snapshot.py +++ b/backend/apps/owasp/api/internal/queries/member_snapshot.py @@ -1,17 +1,20 @@ """OWASP member snapshot GraphQL queries.""" import strawberry +import strawberry_django 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 +MAX_LIMIT = 1000 + @strawberry.type class MemberSnapshotQuery: """GraphQL queries for MemberSnapshot model.""" - @strawberry.field + @strawberry_django.field def member_snapshot( self, user_login: str, start_year: int | None = None ) -> MemberSnapshotNode | None: @@ -28,7 +31,11 @@ def member_snapshot( try: user = User.objects.get(login=user_login) - query = MemberSnapshot.objects.filter(github_user=user) + query = ( + MemberSnapshot.objects.select_related("github_user") + .prefetch_related("issues", "pull_requests", "messages") + .filter(github_user=user) + ) if start_year: query = query.filter(start_at__year=start_year) @@ -38,7 +45,7 @@ def member_snapshot( except User.DoesNotExist: return None - @strawberry.field + @strawberry_django.field def member_snapshots( self, user_login: str | None = None, limit: int = 10 ) -> list[MemberSnapshotNode]: @@ -52,13 +59,14 @@ def member_snapshots( List of MemberSnapshotNode objects """ - query = MemberSnapshot.objects.all() + snapshots = MemberSnapshot.objects.all().select_related("github_user") if user_login: try: - user = User.objects.get(login=user_login) - query = query.filter(github_user=user) + snapshots = snapshots.filter(github_user=User.objects.get(login=user_login)) except User.DoesNotExist: return [] - return query.order_by("-start_at")[:limit] + return ( + snapshots.order_by("-start_at")[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] + ) diff --git a/backend/apps/owasp/api/internal/queries/post.py b/backend/apps/owasp/api/internal/queries/post.py index 28c1d88817..a7f4ca3d23 100644 --- a/backend/apps/owasp/api/internal/queries/post.py +++ b/backend/apps/owasp/api/internal/queries/post.py @@ -1,16 +1,19 @@ """OWASP event GraphQL queries.""" import strawberry +import strawberry_django from apps.owasp.api.internal.nodes.post import PostNode from apps.owasp.models.post import Post +MAX_LIMIT = 1000 + @strawberry.type class PostQuery: """GraphQL queries for Post model.""" - @strawberry.field + @strawberry_django.field def recent_posts(self, limit: int = 5) -> list[PostNode]: - """Return the 5 most recent posts.""" - return Post.recent_posts()[:limit] + """Return the most recent posts.""" + return Post.recent_posts()[:limit] if (limit := min(limit, MAX_LIMIT)) > 0 else [] diff --git a/backend/apps/owasp/api/internal/queries/project.py b/backend/apps/owasp/api/internal/queries/project.py index 154e25bfe9..af4db3c009 100644 --- a/backend/apps/owasp/api/internal/queries/project.py +++ b/backend/apps/owasp/api/internal/queries/project.py @@ -1,18 +1,24 @@ """OWASP project GraphQL queries.""" import strawberry +import strawberry_django from django.db.models import Q 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 +MAX_RECENT_PROJECTS_LIMIT = 1000 +MAX_SEARCH_QUERY_LENGTH = 100 +MIN_SEARCH_QUERY_LENGTH = 3 +SEARCH_PROJECTS_LIMIT = 3 + @strawberry.type class ProjectQuery: """Project queries.""" - @strawberry.field + @strawberry_django.field def project(self, key: str) -> ProjectNode | None: """Resolve project. @@ -28,7 +34,7 @@ def project(self, key: str) -> ProjectNode | None: except Project.DoesNotExist: return None - @strawberry.field + @strawberry_django.field def recent_projects(self, limit: int = 8) -> list[ProjectNode]: """Resolve recent projects. @@ -39,20 +45,28 @@ 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] + return ( + Project.objects.filter(is_active=True).order_by("-created_at")[:limit] + if (limit := min(limit, MAX_RECENT_PROJECTS_LIMIT)) > 0 + else [] + ) - @strawberry.field + @strawberry_django.field def search_projects(self, query: str) -> list[ProjectNode]: """Search active projects by name (case-insensitive, partial match).""" - if not query.strip(): + 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=query.strip(), - ).order_by("name")[:3] + name__icontains=cleaned_query, + ).order_by("name")[:SEARCH_PROJECTS_LIMIT] - @strawberry.field + @strawberry_django.field def is_project_leader(self, info: strawberry.Info, login: str) -> bool: """Check if a GitHub login or name is listed as a project leader.""" try: diff --git a/backend/apps/owasp/api/internal/queries/project_health_metrics.py b/backend/apps/owasp/api/internal/queries/project_health_metrics.py index ead03bf330..d133da590a 100644 --- a/backend/apps/owasp/api/internal/queries/project_health_metrics.py +++ b/backend/apps/owasp/api/internal/queries/project_health_metrics.py @@ -2,6 +2,7 @@ import strawberry import strawberry_django +from strawberry.types.unset import UNSET from apps.owasp.api.internal.filters.project_health_metrics import ProjectHealthMetricsFilter from apps.owasp.api.internal.nodes.project_health_metrics import ProjectHealthMetricsNode @@ -10,6 +11,9 @@ from apps.owasp.api.internal.permissions.project_health_metrics import HasDashboardAccess from apps.owasp.models.project_health_metrics import ProjectHealthMetrics +MAX_LIMIT = 1000 +MAX_OFFSET = 10000 + @strawberry.type class ProjectHealthMetricsQuery: @@ -39,9 +43,19 @@ def project_health_metrics( list[ProjectHealthMetricsNode]: List of project health metrics. """ + if pagination: + if pagination.offset < 0: + return [] + pagination.offset = min(pagination.offset, MAX_OFFSET) + + if pagination.limit is not None and pagination.limit != UNSET: + if pagination.limit <= 0: + return [] + pagination.limit = min(pagination.limit, MAX_LIMIT) + return ProjectHealthMetrics.get_latest_health_metrics() - @strawberry.field( + @strawberry_django.field( permission_classes=[HasDashboardAccess], ) def project_health_stats(self) -> ProjectHealthStatsNode: diff --git a/backend/apps/owasp/api/internal/queries/snapshot.py b/backend/apps/owasp/api/internal/queries/snapshot.py index a70c8d264d..d649acad34 100644 --- a/backend/apps/owasp/api/internal/queries/snapshot.py +++ b/backend/apps/owasp/api/internal/queries/snapshot.py @@ -1,16 +1,19 @@ """OWASP snapshot GraphQL queries.""" import strawberry +import strawberry_django from apps.owasp.api.internal.nodes.snapshot import SnapshotNode from apps.owasp.models.snapshot import Snapshot +MAX_LIMIT = 100 + @strawberry.type class SnapshotQuery: """Snapshot queries.""" - @strawberry.field + @strawberry_django.field def snapshot(self, key: str) -> SnapshotNode | None: """Resolve snapshot by key.""" try: @@ -21,11 +24,15 @@ def snapshot(self, key: str) -> SnapshotNode | None: except Snapshot.DoesNotExist: return None - @strawberry.field + @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] + return ( + Snapshot.objects.filter( + status=Snapshot.Status.COMPLETED, + ).order_by( + "-created_at", + )[:limit] + if (limit := min(limit, MAX_LIMIT)) > 0 + else [] + ) diff --git a/backend/apps/owasp/api/internal/queries/sponsor.py b/backend/apps/owasp/api/internal/queries/sponsor.py index 3e692a7ec8..80014e529f 100644 --- a/backend/apps/owasp/api/internal/queries/sponsor.py +++ b/backend/apps/owasp/api/internal/queries/sponsor.py @@ -1,6 +1,7 @@ """OWASP sponsors GraphQL queries.""" import strawberry +import strawberry_django from apps.owasp.api.internal.nodes.sponsor import SponsorNode from apps.owasp.models.sponsor import Sponsor @@ -10,7 +11,7 @@ class SponsorQuery: """Sponsor queries.""" - @strawberry.field + @strawberry_django.field def sponsors(self) -> list[SponsorNode]: """Resolve sponsors.""" return sorted( diff --git a/backend/apps/owasp/api/internal/views/permissions.py b/backend/apps/owasp/api/internal/views/permissions.py index c193e0c985..63d94ee302 100644 --- a/backend/apps/owasp/api/internal/views/permissions.py +++ b/backend/apps/owasp/api/internal/views/permissions.py @@ -2,12 +2,17 @@ from functools import wraps +from django.conf import settings from django.http import HttpResponseForbidden def has_dashboard_permission(request): """Check if user has dashboard access.""" - return (user := request.user) and user.is_authenticated and user.github_user.is_owasp_staff + return ( + True + if settings.IS_E2E_ENVIRONMENT or settings.IS_FUZZ_ENVIRONMENT + else (user := request.user) and user.is_authenticated and user.github_user.is_owasp_staff + ) def dashboard_access_required(view_func): diff --git a/backend/apps/owasp/migrations/0070_snapshot_owasp_snapshot_created_at_idx_and_more.py b/backend/apps/owasp/migrations/0070_snapshot_owasp_snapshot_created_at_idx_and_more.py new file mode 100644 index 0000000000..b9c3133229 --- /dev/null +++ b/backend/apps/owasp/migrations/0070_snapshot_owasp_snapshot_created_at_idx_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0 on 2026-01-06 09:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0040_merge_20251117_0136"), + ("owasp", "0069_alter_project_contribution_data_and_more"), + ] + + operations = [ + migrations.AddIndex( + model_name="snapshot", + index=models.Index(fields=["-created_at"], name="owasp_snapshot_created_at_idx"), + ), + migrations.AddIndex( + model_name="snapshot", + index=models.Index(fields=["key", "status"], name="owasp_snapshot_key_status_idx"), + ), + ] diff --git a/backend/apps/owasp/migrations/0071_trigram_extension.py b/backend/apps/owasp/migrations/0071_trigram_extension.py new file mode 100644 index 0000000000..123b3876dd --- /dev/null +++ b/backend/apps/owasp/migrations/0071_trigram_extension.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0 on 2026-01-07 11:29 + +from django.contrib.postgres.operations import TrigramExtension +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0070_snapshot_owasp_snapshot_created_at_idx_and_more"), + ] + + operations = [ + TrigramExtension(), + ] diff --git a/backend/apps/owasp/migrations/0072_project_project_name_gin_idx_and_more.py b/backend/apps/owasp/migrations/0072_project_project_name_gin_idx_and_more.py new file mode 100644 index 0000000000..f7367a2404 --- /dev/null +++ b/backend/apps/owasp/migrations/0072_project_project_name_gin_idx_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0 on 2026-01-07 12:00 + +import django.contrib.postgres.indexes +import django.db.models.functions.comparison +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0040_merge_20251117_0136"), + ("owasp", "0071_trigram_extension"), + ] + + operations = [ + migrations.AddIndex( + model_name="project", + index=django.contrib.postgres.indexes.GinIndex( + condition=models.Q(("is_active", True)), + fields=["name"], + name="project_name_gin_idx", + opclasses=["gin_trgm_ops"], + ), + ), + migrations.AddIndex( + model_name="project", + index=django.contrib.postgres.indexes.GinIndex( + django.contrib.postgres.indexes.OpClass( + django.db.models.functions.comparison.Cast("leaders_raw", models.TextField()), + name="gin_trgm_ops", + ), + name="project_leaders_raw_gin_idx", + ), + ), + ] diff --git a/backend/apps/owasp/models/chapter.py b/backend/apps/owasp/models/chapter.py index fd199e12af..963e1be5db 100644 --- a/backend/apps/owasp/models/chapter.py +++ b/backend/apps/owasp/models/chapter.py @@ -8,7 +8,6 @@ from django.db import models from apps.common.geocoding import get_location_coordinates -from apps.common.index import IndexBase from apps.common.models import BulkSaveModel, TimestampedModel from apps.common.open_ai import OpenAi from apps.common.utils import get_absolute_url, join_values @@ -98,7 +97,12 @@ def nest_url(self) -> str: @lru_cache def active_chapters_count(): """Return active chapters count.""" - return IndexBase.get_total_count("chapters", search_filters="idx_is_active:true") + return Chapter.objects.filter( + is_active=True, + latitude__isnull=False, + longitude__isnull=False, + owasp_repository__is_empty=False, + ).count() def from_github(self, repository) -> None: """Update instance based on GitHub repository data. diff --git a/backend/apps/owasp/models/committee.py b/backend/apps/owasp/models/committee.py index 658acc718f..aed85a5188 100644 --- a/backend/apps/owasp/models/committee.py +++ b/backend/apps/owasp/models/committee.py @@ -5,7 +5,6 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from apps.common.index import IndexBase from apps.common.models import BulkSaveModel, TimestampedModel from apps.core.models.prompt import Prompt from apps.owasp.models.common import RepositoryBasedEntityModel @@ -67,7 +66,9 @@ def nest_key(self): @lru_cache def active_committees_count(): """Return active committees count.""" - return IndexBase.get_total_count("committees") + return Committee.objects.filter( + has_active_repositories=True, + ).count() @staticmethod def bulk_save(committees, fields=None) -> None: # type: ignore[override] diff --git a/backend/apps/owasp/models/common.py b/backend/apps/owasp/models/common.py index e095236a9c..376613a810 100644 --- a/backend/apps/owasp/models/common.py +++ b/backend/apps/owasp/models/common.py @@ -4,9 +4,11 @@ import logging import re +from functools import cached_property from urllib.parse import urlparse import yaml +from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models @@ -88,16 +90,18 @@ class Meta: blank=True, ) - @property - def entity_leaders(self) -> models.QuerySet[EntityMember]: + # GRs. + entity_members = GenericRelation( + EntityMember, + content_type_field="entity_type", + object_id_field="entity_id", + related_query_name="entity", + ) + + @cached_property + def entity_leaders(self) -> list[EntityMember]: """Return entity's leaders.""" - return EntityMember.objects.filter( - entity_id=self.id, - entity_type=ContentType.objects.get_for_model(self.__class__), - is_active=True, - is_reviewed=True, - role=EntityMember.Role.LEADER, - ).order_by("order") + return self.entity_members.filter(role=EntityMember.Role.LEADER).order_by("order") @property def github_url(self) -> str: diff --git a/backend/apps/owasp/models/mixins/committee.py b/backend/apps/owasp/models/mixins/committee.py index d11d0312af..7c3ea7615c 100644 --- a/backend/apps/owasp/models/mixins/committee.py +++ b/backend/apps/owasp/models/mixins/committee.py @@ -12,7 +12,7 @@ class CommitteeIndexMixin(RepositoryBasedEntityModelMixin): @property def idx_created_at(self): """Return created at for indexing.""" - return self.created_at.timestamp() + return self.created_at.timestamp() if self.created_at else None @property def idx_key(self): @@ -32,4 +32,4 @@ def idx_top_contributors(self) -> list[str]: @property def idx_updated_at(self): """Return updated at for indexing.""" - return self.updated_at.timestamp() + return self.updated_at.timestamp() if self.updated_at else None diff --git a/backend/apps/owasp/models/project.py b/backend/apps/owasp/models/project.py index 9c3af5c1ba..0a6ecdcafb 100644 --- a/backend/apps/owasp/models/project.py +++ b/backend/apps/owasp/models/project.py @@ -7,10 +7,10 @@ from typing import TYPE_CHECKING from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.indexes import GinIndex, OpClass from django.db import models from django.utils import timezone -from apps.common.index import IndexBase from apps.common.models import BulkSaveModel, TimestampedModel from apps.common.utils import get_absolute_url from apps.core.models.prompt import Prompt @@ -26,10 +26,10 @@ ) from apps.owasp.models.managers.project import ActiveProjectManager from apps.owasp.models.mixins.project import ProjectIndexMixin -from apps.owasp.models.project_health_metrics import ProjectHealthMetrics if TYPE_CHECKING: from apps.owasp.models.entity_member import EntityMember + from apps.owasp.models.project_health_metrics import ProjectHealthMetrics MAX_LEADERS_COUNT = 5 @@ -50,6 +50,18 @@ class Meta: indexes = [ models.Index(fields=["-created_at"], name="project_created_at_desc_idx"), models.Index(fields=["-updated_at"], name="project_updated_at_desc_idx"), + GinIndex( + fields=["name"], + name="project_name_gin_idx", + opclasses=["gin_trgm_ops"], + condition=models.Q(is_active=True), + ), + GinIndex( + OpClass( + models.functions.Cast("leaders_raw", models.TextField()), name="gin_trgm_ops" + ), + name="project_leaders_raw_gin_idx", + ), ] verbose_name_plural = "Projects" @@ -146,7 +158,7 @@ def __str__(self) -> str: return f"{self.name or self.key}" @property - def entity_leaders(self) -> models.QuerySet[EntityMember]: + def entity_leaders(self) -> list[EntityMember]: """Return project leaders.""" return super().entity_leaders[:MAX_LEADERS_COUNT] @@ -185,10 +197,20 @@ def is_tool_type(self) -> bool: @property def issues(self): """Return issues.""" - return Issue.objects.filter( - repository__in=self.repositories.all(), - ).select_related( - "repository", + return ( + Issue.objects.filter( + repository__in=self.repositories.all(), + ) + .select_related( + "author", + "level", + "milestone", + "repository", + ) + .prefetch_related( + "assignees", + "labels", + ) ) @property @@ -199,9 +221,7 @@ def issues_count(self) -> int: @property def last_health_metrics(self) -> ProjectHealthMetrics | None: """Return last health metrics for the project.""" - return ( - ProjectHealthMetrics.objects.filter(project=self).order_by("-nest_created_at").first() - ) + return self.health_metrics.order_by("-nest_created_at").first() @property def leaders_count(self) -> int: @@ -240,10 +260,20 @@ def owasp_page_last_updated_at(self) -> datetime.datetime | None: @property def pull_requests(self): """Return pull requests.""" - return PullRequest.objects.filter( - repository__in=self.repositories.all(), - ).select_related( - "repository", + return ( + PullRequest.objects.filter( + repository__in=self.repositories.all(), + ) + .select_related( + "author", + "milestone", + "repository__organization", + "repository", + ) + .prefetch_related( + "assignees", + "labels", + ) ) @property @@ -266,16 +296,25 @@ def published_releases(self): published_at__isnull=False, repository__in=self.repositories.all(), ).select_related( + "author", "repository", + "repository__organization", ) @property def recent_milestones(self): """Return recent milestones.""" - return Milestone.objects.filter( - repository__in=self.repositories.all(), - ).select_related( - "repository", + return ( + Milestone.objects.filter( + repository__in=self.repositories.all(), + ) + .select_related( + "author", + "repository", + ) + .prefetch_related( + "labels", + ) ) @property @@ -360,7 +399,10 @@ def save(self, *args, **kwargs) -> None: @lru_cache def active_projects_count(): """Return active projects count.""" - return IndexBase.get_total_count("projects", search_filters="idx_is_active:true") + return Project.objects.filter( + has_active_repositories=True, + is_active=True, + ).count() @staticmethod def bulk_save(projects: list, fields: list | None = None) -> None: # type: ignore[override] diff --git a/backend/apps/owasp/models/project_health_metrics.py b/backend/apps/owasp/models/project_health_metrics.py index 36a88c23ea..8e055cf04a 100644 --- a/backend/apps/owasp/models/project_health_metrics.py +++ b/backend/apps/owasp/models/project_health_metrics.py @@ -1,8 +1,10 @@ """Project health metrics model.""" +from functools import cached_property + from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models.functions import ExtractMonth, TruncDate +from django.db.models.functions import Coalesce, ExtractMonth, TruncDate from django.utils import timezone from apps.common.models import BulkSaveModel, TimestampedModel @@ -90,7 +92,7 @@ def age_days(self) -> int: @property def age_days_requirement(self) -> int: """Get the age requirement for the project.""" - return self.project_requirements.age_days + return self.project_requirements.age_days if self.project_requirements else 0 @property def last_commit_days(self) -> int: @@ -100,7 +102,7 @@ def last_commit_days(self) -> int: @property def last_commit_days_requirement(self) -> int: """Get the last commit requirement for the project.""" - return self.project_requirements.last_commit_days + return self.project_requirements.last_commit_days if self.project_requirements else 0 @property def last_pull_request_days(self) -> int: @@ -114,7 +116,7 @@ def last_pull_request_days(self) -> int: @property def last_pull_request_days_requirement(self) -> int: """Get the last pull request requirement for the project.""" - return self.project_requirements.last_pull_request_days + return self.project_requirements.last_pull_request_days if self.project_requirements else 0 @property def last_release_days(self) -> int: @@ -124,7 +126,7 @@ def last_release_days(self) -> int: @property def last_release_days_requirement(self) -> int: """Get the last release requirement for the project.""" - return self.project_requirements.last_release_days + return self.project_requirements.last_release_days if self.project_requirements else 0 @property def owasp_page_last_update_days(self) -> int: @@ -138,12 +140,16 @@ def owasp_page_last_update_days(self) -> int: @property def owasp_page_last_update_days_requirement(self) -> int: """Get the OWASP page last update requirement for the project.""" - return self.project_requirements.owasp_page_last_update_days + return ( + self.project_requirements.owasp_page_last_update_days + if self.project_requirements + else 0 + ) - @property - def project_requirements(self) -> ProjectHealthRequirements: + @cached_property + def project_requirements(self) -> ProjectHealthRequirements | None: """Get the project health requirements for the project's level.""" - return ProjectHealthRequirements.objects.get(level=self.project.level) + return ProjectHealthRequirements.objects.filter(level=self.project.level).first() @staticmethod def bulk_save(metrics: list, fields: list | None = None) -> None: # type: ignore[override] @@ -164,13 +170,14 @@ def get_latest_health_metrics() -> models.QuerySet["ProjectHealthMetrics"]: QuerySet[ProjectHealthMetrics]: QuerySet of project health metrics. """ + # To have a queryset that supports further filtering/ordering, + # we use a subquery to get the latest metrics per project. return ProjectHealthMetrics.objects.filter( - nest_created_at=models.Subquery( - ProjectHealthMetrics.objects.filter(project=models.OuterRef("project")) - .order_by("-nest_created_at") - .values("nest_created_at")[:1] - ), - project__is_active=True, + id__in=ProjectHealthMetrics.objects.filter(project__is_active=True) + .select_related("project") + .order_by("project_id", "-nest_created_at") + .distinct("project_id") + .values_list("id", flat=True) ) @staticmethod @@ -181,27 +188,27 @@ def get_stats() -> ProjectHealthStatsNode: ProjectHealthStatsNode: The overall health stats of all projects. """ - metrics = ProjectHealthMetrics.get_latest_health_metrics() - - projects_count_healthy = metrics.filter( - score__gte=HEALTH_SCORE_THRESHOLD_HEALTHY, - ).count() - projects_count_need_attention = metrics.filter( - score__lt=HEALTH_SCORE_THRESHOLD_HEALTHY, - score__gte=HEALTH_SCORE_THRESHOLD_NEED_ATTENTION, - ).count() - projects_count_unhealthy = metrics.filter( - score__lt=HEALTH_SCORE_THRESHOLD_NEED_ATTENTION - ).count() - - projects_count_total = metrics.count() or 1 # Avoid division by zero - - aggregation = metrics.aggregate( - average_score=models.Avg("score"), - total_contributors=models.Sum("contributors_count"), - total_forks=models.Sum("forks_count"), - total_stars=models.Sum("stars_count"), + stats = ProjectHealthMetrics.get_latest_health_metrics().aggregate( + projects_count_healthy=models.Count( + "id", filter=models.Q(score__gte=HEALTH_SCORE_THRESHOLD_HEALTHY) + ), + projects_count_need_attention=models.Count( + "id", + filter=models.Q( + score__lt=HEALTH_SCORE_THRESHOLD_HEALTHY, + score__gte=HEALTH_SCORE_THRESHOLD_NEED_ATTENTION, + ), + ), + projects_count_unhealthy=models.Count( + "id", filter=models.Q(score__lt=HEALTH_SCORE_THRESHOLD_NEED_ATTENTION) + ), + projects_count_total=models.Count("id"), + average_score=Coalesce(models.Avg("score"), 0.0), + total_contributors=Coalesce(models.Sum("contributors_count"), 0), + total_forks=Coalesce(models.Sum("forks_count"), 0), + total_stars=Coalesce(models.Sum("stars_count"), 0), ) + total = stats["projects_count_total"] or 1 # Avoid division by zero monthly_overall_metrics = ( ProjectHealthMetrics.objects.annotate(month=ExtractMonth("nest_created_at")) .filter( @@ -214,22 +221,26 @@ def get_stats() -> ProjectHealthStatsNode: score=models.Avg("score"), ) ) + months = [] + scores = [] + for entry in monthly_overall_metrics: + months.append(entry["month"]) + scores.append(entry["score"]) + return ProjectHealthStatsNode( - average_score=aggregation.get("average_score", 0.0), + average_score=stats["average_score"], # We use all metrics instead of latest metrics to get the monthly trend - monthly_overall_scores=list(monthly_overall_metrics.values_list("score", flat=True)), - monthly_overall_scores_months=list( - monthly_overall_metrics.values_list("month", flat=True) - ), - projects_count_healthy=projects_count_healthy, - projects_count_need_attention=projects_count_need_attention, - projects_count_unhealthy=projects_count_unhealthy, - projects_percentage_healthy=(projects_count_healthy / projects_count_total) * 100, + monthly_overall_scores=scores, + monthly_overall_scores_months=months, + projects_count_healthy=stats["projects_count_healthy"], + projects_count_need_attention=stats["projects_count_need_attention"], + projects_count_unhealthy=stats["projects_count_unhealthy"], + projects_percentage_healthy=(stats["projects_count_healthy"] / total) * 100, projects_percentage_need_attention=( - (projects_count_need_attention / projects_count_total) * 100 + (stats["projects_count_need_attention"] / total) * 100 ), - projects_percentage_unhealthy=(projects_count_unhealthy / projects_count_total) * 100, - total_contributors=(aggregation.get("total_contributors", 0)), - total_forks=(aggregation.get("total_forks", 0)), - total_stars=(aggregation.get("total_stars", 0)), + projects_percentage_unhealthy=(stats["projects_count_unhealthy"] / total) * 100, + total_contributors=(stats["total_contributors"]), + total_forks=(stats["total_forks"]), + total_stars=(stats["total_stars"]), ) diff --git a/backend/apps/owasp/models/snapshot.py b/backend/apps/owasp/models/snapshot.py index f71f43bd22..960130a73d 100644 --- a/backend/apps/owasp/models/snapshot.py +++ b/backend/apps/owasp/models/snapshot.py @@ -11,6 +11,11 @@ class Meta: db_table = "owasp_snapshots" verbose_name_plural = "Snapshots" + indexes = [ + models.Index(fields=["-created_at"], name="owasp_snapshot_created_at_idx"), + models.Index(fields=["key", "status"], name="owasp_snapshot_key_status_idx"), + ] + class Status(models.TextChoices): PENDING = "pending", "Pending" PROCESSING = "processing", "Processing" diff --git a/backend/data/nest.dump b/backend/data/nest.dump new file mode 100644 index 0000000000..629fecd4b1 Binary files /dev/null and b/backend/data/nest.dump differ diff --git a/backend/entrypoint.fuzz.sh b/backend/entrypoint.fuzz.sh new file mode 100644 index 0000000000..bbfbab7c52 --- /dev/null +++ b/backend/entrypoint.fuzz.sh @@ -0,0 +1,49 @@ +#!/bin/sh + +set -e + +if [ -z "$BASE_URL" ]; then + echo "Error: BASE_URL environment variable is not set." >&2 + exit 1 +fi + +echo "Fetching CSRF token..." +CSRF_TOKEN=$(curl -fsSL "$BASE_URL/csrf" | jq -r '.csrftoken') + +if [ -z "$CSRF_TOKEN" ] || [ "$CSRF_TOKEN" = "null" ]; then + echo "Error: Failed to retrieve CSRF token." >&2 + exit 1 +fi + +export CSRF_TOKEN + +echo "CSRF token retrieved successfully." +:> ./schemathesis.toml + +# Number of examples to generate per endpoint +# See https://schemathesis.readthedocs.io/en/stable/explanations/data-generation/#how-many-test-cases-does-schemathesis-generate + +echo "generation.max-examples = 100" >> ./schemathesis.toml + +# Enable specific checks +# See https://schemathesis.readthedocs.io/en/stable/reference/checks/ +# Schemathesis raises errors for bad requests, so we need to explicitly enable the checks we want +echo "[checks]" >> ./schemathesis.toml +echo "enabled = false" >> ./schemathesis.toml +echo "response_schema_conformance.enabled = true" >> ./schemathesis.toml +echo "not_a_server_error.enabled = true" >> ./schemathesis.toml + +if [ -n "$TEST_FILE" ]; then + echo "Using test file: $TEST_FILE" +else + echo "Error: TEST_FILE environment variable is not set." >&2 + exit 1 +fi + +echo "Starting fuzzing process..." + +if [ -n "$CI" ]; then + pytest ./tests/${TEST_FILE} +else + pytest --log-cli-level=INFO -s ./tests/${TEST_FILE} +fi diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 81f7706fb1..ccb0b1b2af 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -102,6 +102,10 @@ lint.per-file-ignores."**/management/commands/*.py" = [ "SLF001", # https://docs.astral.sh/ruff/rules/private-member-access/ "T201", # https://docs.astral.sh/ruff/rules/print/ ] +lint.per-file-ignores."**/management/commands/dump_data.py" = [ + "S603", # https://docs.astral.sh/ruff/rules/subprocess-without-shell-equals-true/, + "S607", # https://docs.astral.sh/ruff/rules/start-process-with-partial-path/, +] lint.per-file-ignores."**/migrations/*.py" = [ "D100", # https://docs.astral.sh/ruff/rules/undocumented-public-module/ "D101", # https://docs.astral.sh/ruff/rules/undocumented-public-class/ diff --git a/backend/settings/base.py b/backend/settings/base.py index 4f76af0b50..748615dab3 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -19,7 +19,9 @@ class Base(Configuration): DEBUG = False GITHUB_APP_ID = None GITHUB_APP_INSTALLATION_ID = None + IS_E2E_ENVIRONMENT = False IS_LOCAL_ENVIRONMENT = False + IS_FUZZ_ENVIRONMENT = False IS_PRODUCTION_ENVIRONMENT = False IS_STAGING_ENVIRONMENT = False IS_TEST_ENVIRONMENT = False @@ -91,6 +93,7 @@ class Base(Configuration): "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", + "apps.common.middlewares.block_null_characters.BlockNullCharactersMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ] @@ -137,10 +140,14 @@ class Base(Configuration): REDIS_HOST = values.SecretValue(environ_name="REDIS_HOST") REDIS_PASSWORD = values.SecretValue(environ_name="REDIS_PASSWORD") + REDIS_AUTH_ENABLED = values.BooleanValue(environ_name="REDIS_AUTH_ENABLED", default=True) CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:6379", + "LOCATION": f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:6379" + # GH actions does not support authenticated redis connections. + if REDIS_AUTH_ENABLED + else f"redis://{REDIS_HOST}:6379", "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", }, diff --git a/backend/settings/e2e.py b/backend/settings/e2e.py new file mode 100644 index 0000000000..d70e0fc995 --- /dev/null +++ b/backend/settings/e2e.py @@ -0,0 +1,24 @@ +"""OWASP Nest end-to-end testing configuration.""" + +from configurations import values + +from settings.base import Base + + +class E2E(Base): + """End-to-end testing configuration.""" + + APP_NAME = "OWASP Nest E2E Testing" + SITE_URL = "http://localhost:9000" + + ALLOWED_ORIGINS = ( + "http://frontend:3000", # NOSONAR + "http://localhost:3000", + ) + + CORS_ALLOWED_ORIGINS = ALLOWED_ORIGINS + CSRF_TRUSTED_ORIGINS = ALLOWED_ORIGINS + + IS_E2E_ENVIRONMENT = True + LOGGING = {} + PUBLIC_IP_ADDRESS = values.Value() diff --git a/backend/settings/fuzz.py b/backend/settings/fuzz.py new file mode 100644 index 0000000000..11d3471b59 --- /dev/null +++ b/backend/settings/fuzz.py @@ -0,0 +1,16 @@ +"""OWASP Nest fuzz testing configuration.""" + +from configurations import values + +from settings.base import Base + + +class Fuzz(Base): + """Fuzz testing configuration.""" + + APP_NAME = "OWASP Nest Fuzz Testing" + SITE_URL = "http://localhost:9500" + + IS_FUZZ_ENVIRONMENT = True + LOGGING = {} + PUBLIC_IP_ADDRESS = values.Value() diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py index edb830c1ff..6020741d74 100644 --- a/backend/settings/graphql.py +++ b/backend/settings/graphql.py @@ -1,6 +1,8 @@ """GraphQL schema.""" import strawberry +from strawberry.extensions import QueryDepthLimiter +from strawberry_django.optimizer import DjangoOptimizerExtension from apps.api.internal.mutations import ApiMutations from apps.api.internal.queries import ApiKeyQueries @@ -41,4 +43,8 @@ class Query( """Schema queries.""" -schema = strawberry.Schema(mutation=Mutation, query=Query, extensions=[CacheExtension]) +schema = strawberry.Schema( + mutation=Mutation, + query=Query, + extensions=[CacheExtension, QueryDepthLimiter(max_depth=5), DjangoOptimizerExtension()], +) diff --git a/backend/tests/apps/api/rest/v0/event_test.py b/backend/tests/apps/api/rest/v0/event_test.py index 0ef10beb15..fd2cc2e507 100644 --- a/backend/tests/apps/api/rest/v0/event_test.py +++ b/backend/tests/apps/api/rest/v0/event_test.py @@ -1,43 +1,48 @@ from datetime import datetime import pytest +from django.utils import timezone from apps.api.rest.v0.event import EventDetail +from apps.owasp.models.event import Event as EventModel + +current_timezone = timezone.get_current_timezone() @pytest.mark.parametrize( - "event_data", + "event_object", [ - { - "description": "A test event", - "end_date": "2025-03-14T00:00:00Z", - "key": "test-event", - "latitude": 37.7749, - "longitude": -122.4194, - "name": "Test Event", - "start_date": "2025-03-14T00:00:00Z", - "url": "https://github.com/owasp/Nest", - }, - { - "description": "this is a biggest event", - "end_date": "2023-05-18T00:00:00Z", - "key": "biggest-event", - "latitude": 59.9139, - "longitude": 10.7522, - "name": "biggest event", - "start_date": "2022-05-19T00:00:00Z", - "url": "https://github.com/owasp", - }, + EventModel( + description="this is a sample event", + end_date=datetime(2023, 6, 15, tzinfo=current_timezone).date(), + key="sample-event", + latitude=59.9139, + longitude=10.7522, + name="sample event", + start_date=datetime(2023, 6, 14, tzinfo=current_timezone).date(), + url="https://github.com/owasp/Nest", + ), + EventModel( + description=None, + end_date=None, + key="event-without-end-date", + latitude=None, + longitude=None, + name="event without end date", + start_date=datetime(2023, 7, 1, tzinfo=current_timezone).date(), + url=None, + ), ], ) -def test_event_serializer_validation(event_data): - event = EventDetail(**event_data) +def test_event_serializer_validation(event_object: EventModel): + event = EventDetail.from_orm(event_object) - assert event.description == event_data["description"] - assert event.end_date == datetime.fromisoformat(event_data["end_date"]) - assert event.key == event_data["key"] - assert event.latitude == event_data["latitude"] - assert event.longitude == event_data["longitude"] - assert event.name == event_data["name"] - assert event.start_date == datetime.fromisoformat(event_data["start_date"]) - assert event.url == event_data["url"] + assert event.description == event_object.description + end_date = event_object.end_date.isoformat() if event_object.end_date else None + assert event.end_date == end_date + assert event.key == event_object.key + assert event.latitude == event_object.latitude + assert event.longitude == event_object.longitude + assert event.name == event_object.name + assert event.start_date == event_object.start_date.isoformat() + assert event.url == event_object.url diff --git a/backend/tests/apps/common/graphql_node_base_test.py b/backend/tests/apps/common/graphql_node_base_test.py new file mode 100644 index 0000000000..ee24df72c1 --- /dev/null +++ b/backend/tests/apps/common/graphql_node_base_test.py @@ -0,0 +1,10 @@ +"""Base Test Class for GraphQL Node Tests.""" + + +class GraphQLNodeBaseTest: + """Base Test Class for GraphQL Node Tests.""" + + def _get_field_by_name(self, name, node_class): + return next( + (f for f in node_class.__strawberry_definition__.fields if f.name == name), None + ) diff --git a/backend/tests/apps/common/management/commands/dump_data_test.py b/backend/tests/apps/common/management/commands/dump_data_test.py new file mode 100644 index 0000000000..06bba46cf5 --- /dev/null +++ b/backend/tests/apps/common/management/commands/dump_data_test.py @@ -0,0 +1,100 @@ +from unittest.mock import MagicMock, patch + +from django.core.management import call_command +from django.test import override_settings +from psycopg2 import sql + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "db-name", + "USER": "db-user", + "PASSWORD": "db-pass", # NOSONAR + "HOST": "db-host", + "PORT": "5432", + } +} + + +class TestDumpDataCommand: + @override_settings(DATABASES=DATABASES) + @patch("apps.common.management.commands.dump_data.run") + @patch("apps.common.management.commands.dump_data.connect") + @patch("apps.common.management.commands.dump_data.Path") + def test_dump_data(self, mock_path, mock_connect, mock_run): + # Mock psycopg2 connection/cursor + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_connect.return_value = mock_conn + mock_conn.cursor.return_value.__enter__.return_value = mock_cursor + mock_cursor.fetchall.return_value = [("public.users",), ("public.members",)] + mock_resolve = MagicMock() + mock_path.return_value.resolve.return_value = mock_resolve + call_command( + "dump_data", + "--output", + "data/dump.dump", + ) + + # Verify temp DB created from template + expected_temp_db = "temp_db-name" + mock_connect.assert_any_call( + dbname="postgres", + user="db-user", + # ruff: noqa: S106 + password="db-pass", # NOSONAR + host="db-host", + port="5432", + ) + mock_cursor.execute.assert_any_call( + sql.SQL("CREATE DATABASE {temp_db} TEMPLATE {DB_NAME};").format( + temp_db=sql.Identifier(expected_temp_db), + DB_NAME=sql.Identifier("db-name"), + ) + ) + executed_sql = [str(c.args[0]) for c in mock_cursor.execute.call_args_list] + assert ( + str( + sql.SQL( + """ + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'public' AND column_name = 'email'; + """ + ) + ) + in executed_sql + ) + + assert [ + "pg_dump", + "-h", + "db-host", + "-p", + "5432", + "-U", + "db-user", + "-d", + expected_temp_db, + "--compress=9", + "--data-only", + "--no-owner", + "--no-privileges", + "--format=custom", + "--table=public.owasp_*", + "--table=public.github_*", + "--table=public.slack_members", + "--table=public.slack_workspaces", + "--table=public.slack_conversations", + "--table=public.slack_messages", + "-f", + str(mock_resolve), + ] == mock_run.call_args[0][0] + # Ensure DROP DATABASE executed at the end + mock_cursor.execute.assert_any_call( + sql.SQL("DROP DATABASE IF EXISTS {temp_db};").format( + temp_db=sql.Identifier(expected_temp_db) + ) + ) + mock_path.return_value.resolve.assert_called_once() + mock_resolve.parent.mkdir.assert_called_once_with(parents=True, exist_ok=True) diff --git a/backend/tests/apps/common/management/commands/load_data_test.py b/backend/tests/apps/common/management/commands/load_data_test.py deleted file mode 100644 index 0b384171c1..0000000000 --- a/backend/tests/apps/common/management/commands/load_data_test.py +++ /dev/null @@ -1,64 +0,0 @@ -import contextlib -from unittest.mock import MagicMock, patch - -from apps.common.management.commands.load_data import Command - - -class TestLoadDataCommand: - @patch("apps.core.utils.index.DisableIndexing.unregister_indexes") - @patch("apps.core.utils.index.DisableIndexing.register_indexes") - @patch("apps.common.management.commands.load_data.call_command") - @patch("apps.common.management.commands.load_data.transaction.atomic") - def test_handle( - self, - mock_atomic, - mock_call_command, - mock_register, - mock_unregister, - ): - mock_model = MagicMock() - mock_app_config = MagicMock() - mock_app_config.get_models.return_value = [mock_model] - - mock_atomic.return_value.__enter__ = MagicMock() - mock_atomic.return_value.__exit__ = MagicMock() - - mock_unregister.return_value = None - mock_register.return_value = None - - command = Command() - command.handle() - - mock_unregister.assert_called_once() - mock_register.assert_called_once() - - mock_call_command.assert_called_once_with("loaddata", "data/nest.json.gz", "-v", "3") - - mock_atomic.assert_called_once() - - @patch("apps.core.utils.index.DisableIndexing.unregister_indexes") - @patch("apps.core.utils.index.DisableIndexing.register_indexes") - @patch("apps.common.management.commands.load_data.call_command") - @patch("apps.common.management.commands.load_data.transaction.atomic") - def test_handle_with_exception_during_call_command( - self, - mock_atomic, - mock_call_command, - mock_register, - mock_unregister, - ): - """Test that indexing is re-enabled even if call_command fails.""" - mock_call_command.side_effect = Exception("Call command failed") - - command = Command() - with patch("contextlib.suppress") as mock_suppress: - mock_suppress.return_value.__enter__ = MagicMock() - mock_suppress.return_value.__exit__ = MagicMock() - - with contextlib.suppress(Exception): - command.handle() - - mock_unregister.assert_called_once() - mock_register.assert_called_once() - - mock_atomic.assert_called_once() diff --git a/backend/tests/apps/github/api/internal/nodes/issue_test.py b/backend/tests/apps/github/api/internal/nodes/issue_test.py index b3d4a2dbd9..54a6f61f62 100644 --- a/backend/tests/apps/github/api/internal/nodes/issue_test.py +++ b/backend/tests/apps/github/api/internal/nodes/issue_test.py @@ -3,9 +3,10 @@ from unittest.mock import Mock from apps.github.api.internal.nodes.issue import IssueNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestIssueNode: +class TestIssueNode(GraphQLNodeBaseTest): """Test cases for IssueNode class.""" def test_issue_node_type(self): @@ -32,15 +33,6 @@ def test_issue_node_fields(self): } assert field_names == expected_field_names - def test_author_field(self): - """Test author field resolution.""" - mock_issue = Mock() - mock_author = Mock() - mock_issue.author = mock_author - - result = IssueNode.author(mock_issue) - assert result == mock_author - def test_organization_name_with_organization(self): """Test organization_name field when organization exists.""" mock_issue = Mock() @@ -50,7 +42,8 @@ def test_organization_name_with_organization(self): mock_repository.organization = mock_organization mock_issue.repository = mock_repository - result = IssueNode.organization_name(mock_issue) + field = self._get_field_by_name("organization_name", IssueNode) + result = field.base_resolver.wrapped_func(None, mock_issue) assert result == "test-org" def test_organization_name_without_organization(self): @@ -60,7 +53,8 @@ def test_organization_name_without_organization(self): mock_repository.organization = None mock_issue.repository = mock_repository - result = IssueNode.organization_name(mock_issue) + field = self._get_field_by_name("organization_name", IssueNode) + result = field.base_resolver.wrapped_func(None, mock_issue) assert result is None def test_organization_name_without_repository(self): @@ -68,7 +62,8 @@ def test_organization_name_without_repository(self): mock_issue = Mock() mock_issue.repository = None - result = IssueNode.organization_name(mock_issue) + field = self._get_field_by_name("organization_name", IssueNode) + result = field.base_resolver.wrapped_func(None, mock_issue) assert result is None def test_repository_name_with_repository(self): @@ -78,7 +73,8 @@ def test_repository_name_with_repository(self): mock_repository.name = "test-repo" mock_issue.repository = mock_repository - result = IssueNode.repository_name(mock_issue) + field = self._get_field_by_name("repository_name", IssueNode) + result = field.base_resolver.wrapped_func(None, mock_issue) assert result == "test-repo" def test_repository_name_without_repository(self): @@ -86,5 +82,6 @@ def test_repository_name_without_repository(self): mock_issue = Mock() mock_issue.repository = None - result = IssueNode.repository_name(mock_issue) + field = self._get_field_by_name("repository_name", IssueNode) + result = field.base_resolver.wrapped_func(None, mock_issue) assert result is None diff --git a/backend/tests/apps/github/api/internal/nodes/milestone_test.py b/backend/tests/apps/github/api/internal/nodes/milestone_test.py index a143149c98..7d8dd7567f 100644 --- a/backend/tests/apps/github/api/internal/nodes/milestone_test.py +++ b/backend/tests/apps/github/api/internal/nodes/milestone_test.py @@ -4,9 +4,10 @@ from unittest.mock import Mock from apps.github.api.internal.nodes.milestone import MilestoneNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestMilestoneNode: +class TestMilestoneNode(GraphQLNodeBaseTest): """Test cases for MilestoneNode class.""" def test_milestone_node_type(self): @@ -30,15 +31,6 @@ def test_milestone_node_fields(self): } assert field_names == expected_field_names - def test_author_field(self): - """Test author field resolution.""" - mock_milestone = Mock() - mock_author = Mock() - mock_milestone.author = mock_author - - result = MilestoneNode.author(mock_milestone) - assert result == mock_author - def test_organization_name_with_organization(self): """Test organization_name field when organization exists.""" mock_milestone = Mock() @@ -48,7 +40,8 @@ def test_organization_name_with_organization(self): mock_repository.organization = mock_organization mock_milestone.repository = mock_repository - result = MilestoneNode.organization_name(mock_milestone) + field = self._get_field_by_name("organization_name", MilestoneNode) + result = field.base_resolver.wrapped_func(None, mock_milestone) assert result == "test-org" def test_organization_name_without_organization(self): @@ -58,7 +51,8 @@ def test_organization_name_without_organization(self): mock_repository.organization = None mock_milestone.repository = mock_repository - result = MilestoneNode.organization_name(mock_milestone) + field = self._get_field_by_name("organization_name", MilestoneNode) + result = field.base_resolver.wrapped_func(None, mock_milestone) assert result is None def test_organization_name_without_repository(self): @@ -66,7 +60,8 @@ def test_organization_name_without_repository(self): mock_milestone = Mock() mock_milestone.repository = None - result = MilestoneNode.organization_name(mock_milestone) + field = self._get_field_by_name("organization_name", MilestoneNode) + result = field.base_resolver.wrapped_func(None, mock_milestone) assert result is None def test_progress_with_issues(self): @@ -75,7 +70,8 @@ def test_progress_with_issues(self): mock_milestone.closed_issues_count = 7 mock_milestone.open_issues_count = 3 - result = MilestoneNode.progress(mock_milestone) + field = self._get_field_by_name("progress", MilestoneNode) + result = field.base_resolver.wrapped_func(None, mock_milestone) assert math.isclose(result, 70.0) def test_progress_without_issues(self): @@ -84,7 +80,8 @@ def test_progress_without_issues(self): mock_milestone.closed_issues_count = 0 mock_milestone.open_issues_count = 0 - result = MilestoneNode.progress(mock_milestone) + field = self._get_field_by_name("progress", MilestoneNode) + result = field.base_resolver.wrapped_func(None, mock_milestone) assert math.isclose(result, 0.0) def test_repository_name_with_repository(self): @@ -94,7 +91,8 @@ def test_repository_name_with_repository(self): mock_repository.name = "test-repo" mock_milestone.repository = mock_repository - result = MilestoneNode.repository_name(mock_milestone) + field = self._get_field_by_name("repository_name", MilestoneNode) + result = field.base_resolver.wrapped_func(None, mock_milestone) assert result == "test-repo" def test_repository_name_without_repository(self): @@ -102,5 +100,6 @@ def test_repository_name_without_repository(self): mock_milestone = Mock() mock_milestone.repository = None - result = MilestoneNode.repository_name(mock_milestone) + field = self._get_field_by_name("repository_name", MilestoneNode) + result = field.base_resolver.wrapped_func(None, mock_milestone) assert result is None diff --git a/backend/tests/apps/github/api/internal/nodes/organization_test.py b/backend/tests/apps/github/api/internal/nodes/organization_test.py index 10bc2804a4..cccbb65282 100644 --- a/backend/tests/apps/github/api/internal/nodes/organization_test.py +++ b/backend/tests/apps/github/api/internal/nodes/organization_test.py @@ -6,9 +6,10 @@ OrganizationNode, OrganizationStatsNode, ) +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestOrganizationNode: +class TestOrganizationNode(GraphQLNodeBaseTest): def test_organization_node_inheritance(self): assert hasattr(OrganizationNode, "__strawberry_definition__") @@ -70,7 +71,8 @@ def test_stats_method_with_data(self, mock_repo_contributor_objects, mock_reposi mock_organization = Mock() # Call stats method - result = OrganizationNode.stats(mock_organization) + field = self._get_field_by_name("stats", OrganizationNode) + result = field.base_resolver.wrapped_func(None, mock_organization) # Verify result assert isinstance(result, OrganizationStatsNode) @@ -105,7 +107,8 @@ def test_stats_method_with_none_values( mock_organization = Mock() # Call stats method - result = OrganizationNode.stats(mock_organization) + field = self._get_field_by_name("stats", OrganizationNode) + result = field.base_resolver.wrapped_func(None, mock_organization) # Verify result with default values assert isinstance(result, OrganizationStatsNode) @@ -120,11 +123,12 @@ def test_url_method(self): mock_organization = Mock() mock_organization.url = "https://github.com/test-org" - result = OrganizationNode.url(mock_organization) + field = self._get_field_by_name("url", OrganizationNode) + result = field.base_resolver.wrapped_func(None, mock_organization) assert result == "https://github.com/test-org" -class TestOrganizationStatsNode: +class TestOrganizationStatsNode(GraphQLNodeBaseTest): def test_organization_stats_node(self): expected_fields = { "total_contributors", diff --git a/backend/tests/apps/github/api/internal/nodes/pull_request_test.py b/backend/tests/apps/github/api/internal/nodes/pull_request_test.py index e5cad0f0a0..d0515d7e5e 100644 --- a/backend/tests/apps/github/api/internal/nodes/pull_request_test.py +++ b/backend/tests/apps/github/api/internal/nodes/pull_request_test.py @@ -3,9 +3,10 @@ from unittest.mock import Mock from apps.github.api.internal.nodes.pull_request import PullRequestNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestPullRequestNode: +class TestPullRequestNode(GraphQLNodeBaseTest): """Test cases for PullRequestNode class.""" def test_pull_request_node_type(self): @@ -26,15 +27,6 @@ def test_meta_configuration(self): } assert field_names == expected_field_names - def test_author_field(self): - """Test author field resolution.""" - mock_pr = Mock() - mock_author = Mock() - mock_pr.author = mock_author - - result = PullRequestNode.author(mock_pr) - assert result == mock_author - def test_organization_name_with_organization(self): """Test organization_name field when organization exists.""" mock_pr = Mock() @@ -44,7 +36,8 @@ def test_organization_name_with_organization(self): mock_repository.organization = mock_organization mock_pr.repository = mock_repository - result = PullRequestNode.organization_name(mock_pr) + field = self._get_field_by_name("organization_name", PullRequestNode) + result = field.base_resolver.wrapped_func(None, mock_pr) assert result == "test-org" def test_organization_name_without_organization(self): @@ -54,7 +47,8 @@ def test_organization_name_without_organization(self): mock_repository.organization = None mock_pr.repository = mock_repository - result = PullRequestNode.organization_name(mock_pr) + field = self._get_field_by_name("organization_name", PullRequestNode) + result = field.base_resolver.wrapped_func(None, mock_pr) assert result is None def test_organization_name_without_repository(self): @@ -62,7 +56,8 @@ def test_organization_name_without_repository(self): mock_pr = Mock() mock_pr.repository = None - result = PullRequestNode.organization_name(mock_pr) + field = self._get_field_by_name("organization_name", PullRequestNode) + result = field.base_resolver.wrapped_func(None, mock_pr) assert result is None def test_repository_name_with_repository(self): @@ -72,7 +67,8 @@ def test_repository_name_with_repository(self): mock_repository.name = "test-repo" mock_pr.repository = mock_repository - result = PullRequestNode.repository_name(mock_pr) + field = self._get_field_by_name("repository_name", PullRequestNode) + result = field.base_resolver.wrapped_func(None, mock_pr) assert result == "test-repo" def test_repository_name_without_repository(self): @@ -80,7 +76,8 @@ def test_repository_name_without_repository(self): mock_pr = Mock() mock_pr.repository = None - result = PullRequestNode.repository_name(mock_pr) + field = self._get_field_by_name("repository_name", PullRequestNode) + result = field.base_resolver.wrapped_func(None, mock_pr) assert result is None def test_url_with_url(self): @@ -88,13 +85,6 @@ def test_url_with_url(self): mock_pr = Mock() mock_pr.url = "https://github.com/test-org/test-repo/pull/123" - result = PullRequestNode.url(mock_pr) + field = self._get_field_by_name("url", PullRequestNode) + result = field.base_resolver.wrapped_func(None, mock_pr) assert result == "https://github.com/test-org/test-repo/pull/123" - - def test_url_without_url(self): - """Test url field when URL doesn't exist.""" - mock_pr = Mock() - mock_pr.url = None - - result = PullRequestNode.url(mock_pr) - assert result == "" diff --git a/backend/tests/apps/github/api/internal/nodes/release_test.py b/backend/tests/apps/github/api/internal/nodes/release_test.py index ef45238ffd..f897c8e40c 100644 --- a/backend/tests/apps/github/api/internal/nodes/release_test.py +++ b/backend/tests/apps/github/api/internal/nodes/release_test.py @@ -4,9 +4,10 @@ from apps.github.api.internal.nodes.release import ReleaseNode from apps.github.api.internal.nodes.user import UserNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestReleaseNode: +class TestReleaseNode(GraphQLNodeBaseTest): """Test cases for ReleaseNode class.""" def test_release_node_inheritance(self): @@ -34,15 +35,6 @@ def test_author_field(self): assert author_field is not None assert author_field.type.of_type is UserNode - def test_author_resolution(self): - """Test author field resolution.""" - mock_release = Mock() - mock_author = Mock() - mock_release.author = mock_author - - result = ReleaseNode.author(mock_release) - assert result == mock_author - def test_organization_name_with_organization(self): """Test organization_name field when organization exists.""" mock_release = Mock() @@ -52,7 +44,8 @@ def test_organization_name_with_organization(self): mock_repository.organization = mock_organization mock_release.repository = mock_repository - result = ReleaseNode.organization_name(mock_release) + field = self._get_field_by_name("organization_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result == "test-org" def test_organization_name_without_organization(self): @@ -62,7 +55,8 @@ def test_organization_name_without_organization(self): mock_repository.organization = None mock_release.repository = mock_repository - result = ReleaseNode.organization_name(mock_release) + field = self._get_field_by_name("organization_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result is None def test_organization_name_without_repository(self): @@ -70,7 +64,8 @@ def test_organization_name_without_repository(self): mock_release = Mock() mock_release.repository = None - result = ReleaseNode.organization_name(mock_release) + field = self._get_field_by_name("organization_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result is None def test_project_name_with_project(self): @@ -82,7 +77,8 @@ def test_project_name_with_project(self): mock_repository.project = mock_project mock_release.repository = mock_repository - result = ReleaseNode.project_name(mock_release) + field = self._get_field_by_name("project_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result == " Test Project" # OWASP prefix stripped def test_project_name_without_project(self): @@ -92,7 +88,8 @@ def test_project_name_without_project(self): mock_repository.project = None mock_release.repository = mock_repository - result = ReleaseNode.project_name(mock_release) + field = self._get_field_by_name("project_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result is None def test_project_name_without_repository(self): @@ -100,7 +97,8 @@ def test_project_name_without_repository(self): mock_release = Mock() mock_release.repository = None - result = ReleaseNode.project_name(mock_release) + field = self._get_field_by_name("project_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result is None def test_repository_name_with_repository(self): @@ -110,7 +108,8 @@ def test_repository_name_with_repository(self): mock_repository.name = "test-repo" mock_release.repository = mock_repository - result = ReleaseNode.repository_name(mock_release) + field = self._get_field_by_name("repository_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result == "test-repo" def test_repository_name_without_repository(self): @@ -118,7 +117,8 @@ def test_repository_name_without_repository(self): mock_release = Mock() mock_release.repository = None - result = ReleaseNode.repository_name(mock_release) + field = self._get_field_by_name("repository_name", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result is None def test_url_field(self): @@ -126,5 +126,6 @@ def test_url_field(self): mock_release = Mock() mock_release.url = "https://github.com/test-org/test-repo/releases/tag/v1.0.0" - result = ReleaseNode.url(mock_release) + field = self._get_field_by_name("url", ReleaseNode) + result = field.base_resolver.wrapped_func(None, mock_release) assert result == "https://github.com/test-org/test-repo/releases/tag/v1.0.0" diff --git a/backend/tests/apps/github/api/internal/nodes/repository_test.py b/backend/tests/apps/github/api/internal/nodes/repository_test.py index fd5ef8142c..757b91a0f2 100644 --- a/backend/tests/apps/github/api/internal/nodes/repository_test.py +++ b/backend/tests/apps/github/api/internal/nodes/repository_test.py @@ -8,9 +8,10 @@ from apps.github.api.internal.nodes.release import ReleaseNode from apps.github.api.internal.nodes.repository import RepositoryNode from apps.github.api.internal.nodes.repository_contributor import RepositoryContributorNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestRepositoryNode: +class TestRepositoryNode(GraphQLNodeBaseTest): def test_repository_node_inheritance(self): assert hasattr(RepositoryNode, "__strawberry_definition__") @@ -32,7 +33,6 @@ def test_meta_configuration(self): "name", "open_issues_count", "organization", - "owner_key", "project", "recent_milestones", "releases", @@ -46,58 +46,48 @@ def test_meta_configuration(self): } assert expected_field_names.issubset(field_names) - def _get_field_by_name(self, name): - return next( - (f for f in RepositoryNode.__strawberry_definition__.fields if f.name == name), None - ) - def test_resolve_issues(self): - field = self._get_field_by_name("issues") + field = self._get_field_by_name("issues", RepositoryNode) assert field is not None assert field.type.of_type is IssueNode def test_resolve_languages(self): - field = self._get_field_by_name("languages") + field = self._get_field_by_name("languages", RepositoryNode) assert field is not None assert field.type == list[str] def test_resolve_latest_release(self): - field = self._get_field_by_name("latest_release") + field = self._get_field_by_name("latest_release", RepositoryNode) assert field is not None - assert field.type is str + assert field.type.of_type is str def test_resolve_organization(self): - field = self._get_field_by_name("organization") + field = self._get_field_by_name("organization", RepositoryNode) assert field is not None assert field.type.of_type is OrganizationNode - def test_resolve_owner_key(self): - field = self._get_field_by_name("owner_key") - assert field is not None - assert field.type is str - def test_resolve_recent_milestones(self): - field = self._get_field_by_name("recent_milestones") + field = self._get_field_by_name("recent_milestones", RepositoryNode) assert field is not None assert field.type.of_type is MilestoneNode def test_resolve_releases(self): - field = self._get_field_by_name("releases") + field = self._get_field_by_name("releases", RepositoryNode) assert field is not None assert field.type.of_type is ReleaseNode def test_resolve_top_contributors(self): - field = self._get_field_by_name("top_contributors") + field = self._get_field_by_name("top_contributors", RepositoryNode) assert field is not None assert field.type.of_type is RepositoryContributorNode def test_resolve_topics(self): - field = self._get_field_by_name("topics") + field = self._get_field_by_name("topics", RepositoryNode) assert field is not None assert field.type == list[str] def test_resolve_url(self): - field = self._get_field_by_name("url") + field = self._get_field_by_name("url", RepositoryNode) assert field is not None assert field.type is str @@ -105,21 +95,20 @@ def test_issues_method(self): """Test issues method resolution.""" mock_repository = Mock() mock_issues = Mock() - mock_issues.select_related.return_value.order_by.return_value.__getitem__ = Mock( - return_value=[] - ) + mock_issues.order_by.return_value.__getitem__ = Mock(return_value=[]) mock_repository.issues = mock_issues - RepositoryNode.issues(mock_repository) - mock_issues.select_related.assert_called_with("author") - mock_issues.select_related.return_value.order_by.assert_called_with("-created_at") + field = self._get_field_by_name("issues", RepositoryNode) + field.base_resolver.wrapped_func(None, mock_repository) + mock_issues.order_by.assert_called_with("-created_at") def test_languages_method(self): """Test languages method resolution.""" mock_repository = Mock() mock_repository.languages = {"Python": 1000, "JavaScript": 500} - result = RepositoryNode.languages(mock_repository) + field = self._get_field_by_name("languages", RepositoryNode) + result = field.base_resolver.wrapped_func(None, mock_repository) assert result == ["Python", "JavaScript"] def test_latest_release_method(self): @@ -127,47 +116,31 @@ def test_latest_release_method(self): mock_repository = Mock() mock_repository.latest_release = "v1.0.0" - result = RepositoryNode.latest_release(mock_repository) + field = self._get_field_by_name("latest_release", RepositoryNode) + result = field.base_resolver.wrapped_func(None, mock_repository) assert result == "v1.0.0" - def test_organization_method(self): - """Test organization method resolution.""" - mock_repository = Mock() - mock_organization = Mock() - mock_repository.organization = mock_organization - - result = RepositoryNode.organization(mock_repository) - assert result == mock_organization - - def test_owner_key_method(self): - """Test owner_key method resolution.""" - mock_repository = Mock() - mock_repository.owner_key = "test-owner" - - result = RepositoryNode.owner_key(mock_repository) - assert result == "test-owner" - def test_project_method(self): """Test project method resolution.""" mock_repository = Mock() mock_project = Mock() mock_repository.project = mock_project - result = RepositoryNode.project(mock_repository) + field = self._get_field_by_name("project", RepositoryNode) + result = field.base_resolver.wrapped_func(None, mock_repository) assert result == mock_project def test_recent_milestones_method(self): """Test recent_milestones method resolution.""" mock_repository = Mock() mock_milestones = Mock() - mock_milestones.select_related.return_value.order_by.return_value.__getitem__ = Mock( - return_value=[] - ) + mock_milestones.order_by.return_value.__getitem__ = Mock(return_value=[]) mock_repository.recent_milestones = mock_milestones - RepositoryNode.recent_milestones(mock_repository, limit=3) - mock_milestones.select_related.assert_called_with("repository") - mock_milestones.select_related.return_value.order_by.assert_called_with("-created_at") + field = self._get_field_by_name("recent_milestones", RepositoryNode) + resolver = field.base_resolver.wrapped_func + resolver(None, mock_repository, limit=3) + mock_milestones.order_by.assert_called_with("-created_at") def test_releases_method(self): """Test releases method resolution.""" @@ -176,24 +149,43 @@ def test_releases_method(self): mock_releases.order_by.return_value.__getitem__ = Mock(return_value=[]) mock_repository.published_releases = mock_releases - RepositoryNode.releases(mock_repository) + field = self._get_field_by_name("releases", RepositoryNode) + resolver = field.base_resolver.wrapped_func + resolver(None, mock_repository) mock_releases.order_by.assert_called_with("-published_at") def test_top_contributors_method(self): """Test top_contributors method resolution.""" mock_repository = Mock() - mock_contributors = [Mock(), Mock()] - mock_repository.idx_top_contributors = mock_contributors - - result = RepositoryNode.top_contributors(mock_repository) - assert result == mock_contributors + mock_repository.idx_top_contributors = [ + { + "avatar_url": "url1", + "contributions_count": 100, + "id": "user1", + "login": "user1", + "name": "User 1", + }, + { + "avatar_url": "url2", + "contributions_count": 50, + "id": "user2", + "login": "user2", + "name": "User 2", + }, + ] + + field = self._get_field_by_name("top_contributors", RepositoryNode) + result = field.base_resolver.wrapped_func(None, mock_repository) + assert len(result) == 2 + assert all(isinstance(c, RepositoryContributorNode) for c in result) def test_topics_method(self): """Test topics method resolution.""" mock_repository = Mock() mock_repository.topics = ["security", "python", "django"] - result = RepositoryNode.topics(mock_repository) + field = self._get_field_by_name("topics", RepositoryNode) + result = field.base_resolver.wrapped_func(None, mock_repository) assert result == ["security", "python", "django"] def test_url_method(self): @@ -201,7 +193,8 @@ def test_url_method(self): mock_repository = Mock() mock_repository.url = "https://github.com/test-org/test-repo" - result = RepositoryNode.url(mock_repository) + field = self._get_field_by_name("url", RepositoryNode) + result = field.base_resolver.wrapped_func(None, mock_repository) assert result == "https://github.com/test-org/test-repo" def test_is_archived_field_exists(self): @@ -213,6 +206,6 @@ def test_is_archived_field_exists(self): def test_resolve_is_archived(self): """Test is_archived field type.""" - field = self._get_field_by_name("is_archived") + field = self._get_field_by_name("is_archived", RepositoryNode) assert field is not None assert field.type is bool diff --git a/backend/tests/apps/github/api/internal/nodes/user_test.py b/backend/tests/apps/github/api/internal/nodes/user_test.py index ee4945db26..b9356ec5a1 100644 --- a/backend/tests/apps/github/api/internal/nodes/user_test.py +++ b/backend/tests/apps/github/api/internal/nodes/user_test.py @@ -1,13 +1,15 @@ """Test cases for UserNode.""" import math +from datetime import UTC, datetime from unittest.mock import Mock from apps.github.api.internal.nodes.user import UserNode from apps.nest.api.internal.nodes.badge import BadgeNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestUserNode: +class TestUserNode(GraphQLNodeBaseTest): """Test cases for UserNode class.""" def test_user_node_inheritance(self): @@ -52,7 +54,8 @@ def test_created_at_field(self): mock_user = Mock() mock_user.idx_created_at = 1234567890.0 - result = UserNode.created_at(mock_user) + field = self._get_field_by_name("created_at", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert math.isclose(result, 1234567890.0) def test_issues_count_field(self): @@ -60,7 +63,8 @@ def test_issues_count_field(self): mock_user = Mock() mock_user.idx_issues_count = 42 - result = UserNode.issues_count(mock_user) + field = self._get_field_by_name("issues_count", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == 42 def test_releases_count_field(self): @@ -68,7 +72,8 @@ def test_releases_count_field(self): mock_user = Mock() mock_user.idx_releases_count = 15 - result = UserNode.releases_count(mock_user) + field = self._get_field_by_name("releases_count", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == 15 def test_updated_at_field(self): @@ -76,7 +81,8 @@ def test_updated_at_field(self): mock_user = Mock() mock_user.idx_updated_at = 1234567890.0 - result = UserNode.updated_at(mock_user) + field = self._get_field_by_name("updated_at", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert math.isclose(result, 1234567890.0) def test_url_field(self): @@ -84,7 +90,8 @@ def test_url_field(self): mock_user = Mock() mock_user.url = "https://github.com/testuser" - result = UserNode.url(mock_user) + field = self._get_field_by_name("url", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == "https://github.com/testuser" def test_badge_count_field(self): @@ -94,7 +101,8 @@ def test_badge_count_field(self): mock_badges_queryset.filter.return_value.count.return_value = 3 mock_user.user_badges = mock_badges_queryset - result = UserNode.badge_count(mock_user) + field = self._get_field_by_name("badge_count", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == 3 mock_badges_queryset.filter.assert_called_once_with(is_active=True) mock_badges_queryset.filter.return_value.count.assert_called_once() @@ -108,7 +116,8 @@ def test_badges_field_empty(self): mock_select_related.order_by.return_value = [] mock_user.user_badges = mock_badges_queryset - result = UserNode.badges(mock_user) + field = self._get_field_by_name("badges", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == [] mock_badges_queryset.filter.assert_called_once_with(is_active=True) mock_filter.select_related.assert_called_once_with("badge") @@ -127,7 +136,8 @@ def test_badges_field_single_badge(self): mock_select_related.order_by.return_value = [mock_user_badge] mock_user.user_badges = mock_badges_queryset - result = UserNode.badges(mock_user) + field = self._get_field_by_name("badges", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == [mock_badge] mock_badges_queryset.filter.assert_called_once_with(is_active=True) mock_filter.select_related.assert_called_once_with("badge") @@ -179,7 +189,8 @@ def test_badges_field_sorted_by_weight_and_name(self): mock_user = Mock() mock_user.user_badges = mock_badges_queryset - result = UserNode.badges(mock_user) + field = self._get_field_by_name("badges", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) # Verify the badges are returned in the correct order expected_badges = [ @@ -192,20 +203,20 @@ def test_badges_field_sorted_by_weight_and_name(self): # Verify the queryset was called with correct ordering mock_badges_queryset.filter.assert_called_once_with(is_active=True) - mock_filter.select_related.assert_called_once_with("badge") - mock_select_related.order_by.assert_called_once_with("badge__weight", "badge__name") + mock_filter.select_related.return_value.order_by.assert_called_once_with( + "badge__weight", "badge__name" + ) def test_first_owasp_contribution_at_with_profile(self): """Test first_owasp_contribution_at returns timestamp when profile exists.""" - from datetime import UTC, datetime - mock_profile = Mock() mock_profile.first_contribution_at = datetime(2025, 1, 15, tzinfo=UTC) mock_user = Mock() mock_user.owasp_profile = mock_profile - result = UserNode.first_owasp_contribution_at(mock_user) + field = self._get_field_by_name("first_owasp_contribution_at", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == mock_profile.first_contribution_at.timestamp() @@ -213,7 +224,8 @@ def test_first_owasp_contribution_at_without_profile(self): """Test first_owasp_contribution_at returns None when no profile.""" mock_user = Mock(spec=[]) - result = UserNode.first_owasp_contribution_at(mock_user) + field = self._get_field_by_name("first_owasp_contribution_at", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result is None @@ -225,7 +237,8 @@ def test_is_owasp_board_member_true(self): mock_user = Mock() mock_user.owasp_profile = mock_profile - result = UserNode.is_owasp_board_member(mock_user) + field = self._get_field_by_name("is_owasp_board_member", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result is True @@ -233,7 +246,8 @@ def test_is_owasp_board_member_without_profile(self): """Test is_owasp_board_member returns False when no profile.""" mock_user = Mock(spec=[]) - result = UserNode.is_owasp_board_member(mock_user) + field = self._get_field_by_name("is_owasp_board_member", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result is False @@ -245,7 +259,8 @@ def test_is_former_owasp_staff_true(self): mock_user = Mock() mock_user.owasp_profile = mock_profile - result = UserNode.is_former_owasp_staff(mock_user) + field = self._get_field_by_name("is_former_owasp_staff", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result is True @@ -253,7 +268,8 @@ def test_is_former_owasp_staff_without_profile(self): """Test is_former_owasp_staff returns False when no profile.""" mock_user = Mock(spec=[]) - result = UserNode.is_former_owasp_staff(mock_user) + field = self._get_field_by_name("is_former_owasp_staff", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result is False @@ -265,7 +281,8 @@ def test_is_gsoc_mentor_true(self): mock_user = Mock() mock_user.owasp_profile = mock_profile - result = UserNode.is_gsoc_mentor(mock_user) + field = self._get_field_by_name("is_gsoc_mentor", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result is True @@ -273,7 +290,8 @@ def test_is_gsoc_mentor_without_profile(self): """Test is_gsoc_mentor returns False when no profile.""" mock_user = Mock(spec=[]) - result = UserNode.is_gsoc_mentor(mock_user) + field = self._get_field_by_name("is_gsoc_mentor", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result is False @@ -285,7 +303,8 @@ def test_linkedin_page_id_with_profile_and_value(self): mock_user = Mock() mock_user.owasp_profile = mock_profile - result = UserNode.linkedin_page_id(mock_user) + field = self._get_field_by_name("linkedin_page_id", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == "john-doe-123" @@ -297,7 +316,8 @@ def test_linkedin_page_id_with_profile_empty_value(self): mock_user = Mock() mock_user.owasp_profile = mock_profile - result = UserNode.linkedin_page_id(mock_user) + field = self._get_field_by_name("linkedin_page_id", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == "" @@ -305,6 +325,7 @@ def test_linkedin_page_id_without_profile(self): """Test linkedin_page_id returns empty string when no profile.""" mock_user = Mock(spec=[]) - result = UserNode.linkedin_page_id(mock_user) + field = self._get_field_by_name("linkedin_page_id", UserNode) + result = field.base_resolver.wrapped_func(None, mock_user) assert result == "" diff --git a/backend/tests/apps/github/api/internal/queries/issue_test.py b/backend/tests/apps/github/api/internal/queries/issue_test.py index ac29143a96..2932f68b7a 100644 --- a/backend/tests/apps/github/api/internal/queries/issue_test.py +++ b/backend/tests/apps/github/api/internal/queries/issue_test.py @@ -19,172 +19,161 @@ def mock_issue(self): issue.author_id = 42 return issue - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_basic(self, mock_select_related, mock_issue): + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_basic(self, mock_objects, mock_issue): """Test fetching recent issues with default parameters.""" mock_queryset = MagicMock() - mock_queryset.order_by.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_queryset + mock_queryset.filter.return_value = mock_queryset + mock_queryset.__getitem__.return_value = [mock_issue] + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues() assert result == [mock_issue] - mock_select_related.assert_called_once_with( - "author", "repository", "repository__organization" - ) - mock_queryset.order_by.assert_called_once_with("-created_at") + mock_objects.order_by.assert_called_once_with("-created_at") - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_with_login(self, mock_select_related, mock_issue): + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_with_login(self, mock_objects, mock_issue): """Test filtering issues by login.""" mock_queryset = MagicMock() - mock_queryset.order_by.return_value.filter.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_queryset + mock_queryset.filter.return_value = mock_queryset + mock_queryset.__getitem__.return_value = [mock_issue] + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(login="alice") - mock_select_related.assert_called_once() - mock_queryset.order_by.assert_called_once() - mock_queryset.order_by.return_value.filter.assert_called_once_with(author__login="alice") + mock_queryset.filter.assert_called_once_with(author__login="alice") assert result == [mock_issue] - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_with_organization(self, mock_select_related, mock_issue): + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_with_organization(self, mock_objects, mock_issue): """Test filtering issues by organization.""" mock_queryset = MagicMock() - mock_queryset.order_by.return_value.filter.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_queryset + mock_queryset.filter.return_value = mock_queryset + mock_queryset.__getitem__.return_value = [mock_issue] + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(organization="orgX") - mock_queryset.order_by.assert_called_once() - mock_queryset.order_by.return_value.filter.assert_called_once_with( - repository__organization__login="orgX" - ) + mock_queryset.filter.assert_called_once_with(repository__organization__login="orgX") assert result == [mock_issue] - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_limit(self, mock_select_related, mock_issue): + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_limit(self, mock_objects, mock_issue): """Test limiting the number of issues returned.""" mock_queryset = MagicMock() - mock_queryset.order_by.return_value.__getitem__.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_queryset + mock_queryset.filter.return_value = mock_queryset + mock_queryset.__getitem__.return_value = [mock_issue] + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(limit=1) - mock_select_related.assert_called_once() - mock_queryset.order_by.assert_called_once_with("-created_at") + mock_objects.order_by.assert_called_once_with("-created_at") assert result == [mock_issue] - @patch("apps.github.api.internal.queries.issue.Subquery") - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_distinct(self, mock_select_related, mock_subquery, mock_issue): - """Test distinct filtering with Subquery for issues.""" + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_distinct(self, mock_objects, mock_issue): + """Test distinct filtering with Window/Rank for issues.""" mock_queryset = MagicMock() mock_queryset.order_by.return_value = mock_queryset mock_queryset.filter.return_value = mock_queryset + mock_queryset.annotate.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset - - mock_subquery_instance = MagicMock() - mock_subquery.return_value = mock_subquery_instance + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(distinct=True) assert result == [mock_issue] - mock_subquery.assert_called_once() - mock_queryset.filter.assert_called() - - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_combined_filters(self, mock_select_related, mock_issue): + mock_queryset.annotate.assert_called_once() + # One filter for empty filters dict, one for rank=1 + assert mock_queryset.filter.call_count == 2 + assert ( + mock_queryset.order_by.call_count == 1 + ) # Only initially, distinct adds order_by directly + + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_combined_filters(self, mock_objects, mock_issue): mock_queryset = MagicMock() - mock_queryset.order_by.return_value.filter.return_value.filter.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset + mock_queryset.order_by.return_value = mock_queryset + mock_queryset.filter.return_value = mock_queryset + mock_queryset.__getitem__.return_value = [mock_issue] + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(login="alice", organization="owasp") - mock_select_related.assert_called_once() - mock_queryset.order_by.assert_called_once() + # Both filters are applied in one call using **filters + mock_queryset.filter.assert_called_once_with( + author__login="alice", repository__organization__login="owasp" + ) assert result == [mock_issue] - @patch("apps.github.api.internal.queries.issue.OuterRef") - @patch("apps.github.api.internal.queries.issue.Subquery") - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_distinct_with_organization( - self, mock_select_related, mock_subquery, mock_outer_ref, mock_issue - ): + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_distinct_with_organization(self, mock_objects, mock_issue): """Test distinct filtering with organization filter.""" mock_queryset = MagicMock() mock_queryset.order_by.return_value = mock_queryset mock_queryset.filter.return_value = mock_queryset + mock_queryset.annotate.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset - - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - mock_outer_ref_instance = Mock() - mock_outer_ref.return_value = mock_outer_ref_instance + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(distinct=True, organization="owasp") assert result == [mock_issue] - mock_subquery.assert_called_once() - mock_outer_ref.assert_called_once_with("author_id") - - @patch("apps.github.api.internal.queries.issue.OuterRef") - @patch("apps.github.api.internal.queries.issue.Subquery") - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_distinct_with_all_filters( - self, mock_select_related, mock_subquery, mock_outer_ref, mock_issue - ): + mock_queryset.annotate.assert_called_once() + # One filter for organization, one for rank=1 + assert mock_queryset.filter.call_count == 2 + + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_distinct_with_all_filters(self, mock_objects, mock_issue): """Test distinct filtering with all filters.""" mock_queryset = MagicMock() mock_queryset.order_by.return_value = mock_queryset mock_queryset.filter.return_value = mock_queryset + mock_queryset.annotate.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset - - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - mock_outer_ref_instance = Mock() - mock_outer_ref.return_value = mock_outer_ref_instance + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues( distinct=True, login="alice", organization="owasp", limit=3 ) assert result == [mock_issue] - mock_subquery.assert_called_once() - mock_outer_ref.assert_called_once_with("author_id") - # Verify both login and organization filters were applied - assert mock_queryset.filter.call_count >= 3 # login, organization, distinct + mock_queryset.annotate.assert_called_once() + # One filter for login+organization combined, one for rank=1 + assert mock_queryset.filter.call_count == 2 + mock_queryset.__getitem__.assert_called_with(slice(None, 3)) - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_organization_only(self, mock_select_related, mock_issue): + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_organization_only(self, mock_objects, mock_issue): """Test filtering issues by organization only.""" mock_queryset = MagicMock() mock_queryset.order_by.return_value = mock_queryset mock_queryset.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(organization="owasp") assert result == [mock_issue] - assert len(mock_queryset.filter.call_args_list) >= 1 + mock_queryset.filter.assert_called_once_with(repository__organization__login="owasp") - @patch("apps.github.models.issue.Issue.objects.select_related") - def test_recent_issues_multiple_filters(self, mock_select_related, mock_issue): + @patch("apps.github.models.issue.Issue.objects") + def test_recent_issues_multiple_filters(self, mock_objects, mock_issue): """Test issues with multiple filters applied.""" mock_queryset = MagicMock() mock_queryset.order_by.return_value = mock_queryset mock_queryset.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_issue] - mock_select_related.return_value = mock_queryset + mock_objects.order_by.return_value = mock_queryset result = IssueQuery().recent_issues(organization="owasp", limit=10) assert result == [mock_issue] # Verify organization filter was applied - assert len(mock_queryset.filter.call_args_list) >= 1 + mock_queryset.filter.assert_called_once_with(repository__organization__login="owasp") mock_queryset.__getitem__.assert_called_with(slice(None, 10)) diff --git a/backend/tests/apps/github/api/internal/queries/milestone_test.py b/backend/tests/apps/github/api/internal/queries/milestone_test.py index d1c4689b45..fb4967c6ac 100644 --- a/backend/tests/apps/github/api/internal/queries/milestone_test.py +++ b/backend/tests/apps/github/api/internal/queries/milestone_test.py @@ -3,9 +3,8 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from django.core.exceptions import ValidationError -from apps.github.api.internal.queries.milestone import MilestoneQuery +from apps.github.api.internal.queries.milestone import MilestoneQuery, MilestoneStateEnum from apps.github.models.milestone import Milestone @@ -22,7 +21,6 @@ def mock_milestone(self): def get_queryset(self): """Return a mocked queryset.""" queryset = MagicMock() - queryset.select_related.return_value.prefetch_related.return_value = queryset queryset.filter.return_value = queryset queryset.order_by.return_value = queryset queryset.__getitem__.return_value = [Mock()] @@ -31,9 +29,9 @@ def get_queryset(self): @pytest.mark.parametrize( ("state", "manager"), [ - ("open", "open_milestones"), - ("closed", "closed_milestones"), - ("all", "objects"), + (MilestoneStateEnum.OPEN, "open_milestones"), + (MilestoneStateEnum.CLOSED, "closed_milestones"), + (None, "objects"), ], ) def test_recent_milestones_by_state(self, get_queryset, state, manager): @@ -41,9 +39,6 @@ def test_recent_milestones_by_state(self, get_queryset, state, manager): with patch.object(Milestone, manager, new_callable=Mock) as mock_manager: mock_manager.all.return_value = get_queryset - get_queryset.select_related.return_value = get_queryset - get_queryset.prefetch_related.return_value = get_queryset - result = MilestoneQuery().recent_milestones( distinct=False, limit=5, @@ -53,8 +48,6 @@ def test_recent_milestones_by_state(self, get_queryset, state, manager): ) assert isinstance(result, list) - assert get_queryset.select_related.called - assert get_queryset.prefetch_related.called def test_recent_milestones_with_filters(self, get_queryset): """Test recent milestones with login and organization filters.""" @@ -66,7 +59,7 @@ def test_recent_milestones_with_filters(self, get_queryset): limit=3, login="user", organization="github", - state="open", + state=MilestoneStateEnum.OPEN, ) get_queryset.filter.assert_any_call(author__login="user") @@ -78,7 +71,6 @@ def test_recent_milestones_distinct(self): with patch.object(Milestone, "open_milestones", new_callable=Mock) as mock_manager: base_queryset = MagicMock() filtered_queryset = MagicMock() - base_queryset.select_related.return_value.prefetch_related.return_value = base_queryset base_queryset.filter.return_value = filtered_queryset filtered_queryset.filter.return_value = filtered_queryset filtered_queryset.order_by.return_value = filtered_queryset @@ -90,27 +82,23 @@ def test_recent_milestones_distinct(self): limit=1, login=None, organization=None, - state="open", + state=MilestoneStateEnum.OPEN, ) assert isinstance(result, list) assert filtered_queryset.__getitem__.called - def test_recent_milestones_invalid_state(self): - """Test ValidationError for invalid state parameter.""" - with pytest.raises(ValidationError) as exc_info: - MilestoneQuery().recent_milestones(state="invalid") - - assert "Invalid state: invalid" in str(exc_info.value) - assert "Valid states are 'open', 'closed', or 'all'" in str(exc_info.value) - def test_recent_milestones_with_all_parameters(self, get_queryset): """Test recent milestones with all parameters provided.""" with patch.object(Milestone, "closed_milestones", new_callable=Mock) as mock_manager: mock_manager.all.return_value = get_queryset result = MilestoneQuery().recent_milestones( - distinct=False, limit=10, login="testuser", organization="owasp", state="closed" + distinct=False, + limit=10, + login="testuser", + organization="owasp", + state=MilestoneStateEnum.CLOSED, ) assert isinstance(result, list) diff --git a/backend/tests/apps/github/api/internal/queries/pull_request_test.py b/backend/tests/apps/github/api/internal/queries/pull_request_test.py index 574ea82c8c..911b8e41bc 100644 --- a/backend/tests/apps/github/api/internal/queries/pull_request_test.py +++ b/backend/tests/apps/github/api/internal/queries/pull_request_test.py @@ -23,32 +23,29 @@ def mock_pull_request(self): def mock_queryset(self): """Mock queryset with all necessary methods.""" queryset = MagicMock() - queryset.select_related.return_value = queryset queryset.exclude.return_value = queryset queryset.order_by.return_value = queryset queryset.filter.return_value = queryset + queryset.annotate.return_value = queryset queryset.__getitem__.return_value = [] return queryset @patch("apps.github.models.pull_request.PullRequest.objects") def test_recent_pull_requests_basic(self, mock_objects, mock_queryset, mock_pull_request): """Test fetching recent pull requests with default parameters.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests() assert result == [mock_pull_request] - mock_objects.select_related.assert_called_once_with( - "author", "repository", "repository__organization" - ) - mock_queryset.exclude.assert_called_once_with(author__is_bot=True) + mock_objects.exclude.assert_called_once_with(author__is_bot=True) mock_queryset.order_by.assert_called_once_with("-created_at") @patch("apps.github.models.pull_request.PullRequest.objects") def test_recent_pull_requests_with_login(self, mock_objects, mock_queryset, mock_pull_request): """Test filtering pull requests by login.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests(login="alice") @@ -61,7 +58,7 @@ def test_recent_pull_requests_with_organization( self, mock_objects, mock_queryset, mock_pull_request ): """Test filtering pull requests by organization.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests(organization="owasp") @@ -79,7 +76,7 @@ def test_recent_pull_requests_with_project( mock_project.repositories.values_list.return_value = [1, 2, 3] mock_project_objects.filter.return_value.first.return_value = mock_project - mock_pr_objects.select_related.return_value = mock_queryset + mock_pr_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests(project="test-project") @@ -93,7 +90,7 @@ def test_recent_pull_requests_with_repository( self, mock_objects, mock_queryset, mock_pull_request ): """Test filtering pull requests by repository.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests(repository="test-repo") @@ -101,28 +98,23 @@ def test_recent_pull_requests_with_repository( assert result == [mock_pull_request] mock_queryset.filter.assert_called_with(repository__key__iexact="test-repo") - @patch("apps.github.api.internal.queries.pull_request.Subquery") @patch("apps.github.models.pull_request.PullRequest.objects") - def test_recent_pull_requests_distinct( - self, mock_objects, mock_subquery, mock_queryset, mock_pull_request - ): - """Test distinct filtering with Subquery for pull requests.""" - mock_objects.select_related.return_value = mock_queryset + def test_recent_pull_requests_distinct(self, mock_objects, mock_queryset, mock_pull_request): + """Test distinct filtering with Window/Rank for pull requests.""" + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - result = PullRequestQuery().recent_pull_requests(distinct=True) assert result == [mock_pull_request] + mock_queryset.annotate.assert_called_once() mock_queryset.filter.assert_called() - mock_subquery.assert_called_once() + assert mock_queryset.order_by.call_count == 2 # Once initially, once after filter @patch("apps.github.models.pull_request.PullRequest.objects") def test_recent_pull_requests_with_limit(self, mock_objects, mock_queryset, mock_pull_request): """Test limiting the number of pull requests returned.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests(limit=3) @@ -135,7 +127,7 @@ def test_recent_pull_requests_multiple_filters( self, mock_objects, mock_queryset, mock_pull_request ): """Test pull requests with multiple filters applied.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests( @@ -143,39 +135,33 @@ def test_recent_pull_requests_multiple_filters( ) assert result == [mock_pull_request] - assert mock_queryset.filter.call_count >= 3 + # login+organization applied together, repository applied separately = 2 calls + assert mock_queryset.filter.call_count == 2 mock_queryset.__getitem__.assert_called_with(slice(None, 2)) - @patch("apps.github.api.internal.queries.pull_request.OuterRef") - @patch("apps.github.api.internal.queries.pull_request.Subquery") @patch("apps.github.models.pull_request.PullRequest.objects") def test_recent_pull_requests_distinct_with_filters( - self, mock_objects, mock_subquery, mock_outer_ref, mock_queryset, mock_pull_request + self, mock_objects, mock_queryset, mock_pull_request ): """Test distinct filtering with additional filters.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - mock_outer_ref_instance = Mock() - mock_outer_ref.return_value = mock_outer_ref_instance - result = PullRequestQuery().recent_pull_requests( distinct=True, login="alice", organization="owasp" ) assert result == [mock_pull_request] - mock_queryset.filter.assert_called() - mock_subquery.assert_called_once() - mock_outer_ref.assert_called_once_with("author_id") + mock_queryset.annotate.assert_called_once() + # One filter for login+organization combined, one for rank=1 + assert mock_queryset.filter.call_count == 2 @patch("apps.github.models.pull_request.PullRequest.objects") def test_recent_pull_requests_with_multiple_filters_no_project( self, mock_objects, mock_queryset, mock_pull_request ): """Test pull requests with multiple filters (no project to avoid DB issues).""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] result = PullRequestQuery().recent_pull_requests( @@ -183,30 +169,22 @@ def test_recent_pull_requests_with_multiple_filters_no_project( ) assert result == [mock_pull_request] - # Verify filters were applied - assert mock_queryset.filter.call_count >= 3 # login, organization, repository + # Verify filters were applied: login+organization together, repository separately + assert mock_queryset.filter.call_count == 2 - @patch("apps.github.api.internal.queries.pull_request.OuterRef") - @patch("apps.github.api.internal.queries.pull_request.Subquery") @patch("apps.github.models.pull_request.PullRequest.objects") def test_recent_pull_requests_distinct_comprehensive( - self, mock_objects, mock_subquery, mock_outer_ref, mock_queryset, mock_pull_request + self, mock_objects, mock_queryset, mock_pull_request ): """Test distinct filtering with multiple filters.""" - mock_objects.select_related.return_value = mock_queryset + mock_objects.exclude.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_pull_request] - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - mock_outer_ref_instance = Mock() - mock_outer_ref.return_value = mock_outer_ref_instance - result = PullRequestQuery().recent_pull_requests( distinct=True, login="alice", organization="owasp", repository="test-repo" ) assert result == [mock_pull_request] - mock_subquery.assert_called_once() - mock_outer_ref.assert_called_once_with("author_id") - # Verify multiple filters were applied - assert mock_queryset.filter.call_count >= 4 # login, org, repo, distinct + mock_queryset.annotate.assert_called_once() + # One filter for login+organization, one for repository, one for rank=1 + assert mock_queryset.filter.call_count == 3 diff --git a/backend/tests/apps/github/api/internal/queries/release_test.py b/backend/tests/apps/github/api/internal/queries/release_test.py index 388dc9b00c..fa93cdb5ed 100644 --- a/backend/tests/apps/github/api/internal/queries/release_test.py +++ b/backend/tests/apps/github/api/internal/queries/release_test.py @@ -25,7 +25,7 @@ def mock_queryset(self): queryset = MagicMock() queryset.filter.return_value = queryset queryset.order_by.return_value = queryset - queryset.select_related.return_value = queryset + queryset.annotate.return_value = queryset queryset.__getitem__.return_value = [] return queryset @@ -54,9 +54,6 @@ def test_recent_releases_with_login(self, mock_objects, mock_queryset, mock_rele result = ReleaseQuery().recent_releases(login="alice") assert result == [mock_release] - mock_queryset.select_related.assert_called_with( - "author", "repository", "repository__organization" - ) mock_queryset.filter.assert_called_with(author__login="alice") @patch("apps.github.models.release.Release.objects") @@ -70,23 +67,18 @@ def test_recent_releases_with_organization(self, mock_objects, mock_queryset, mo assert result == [mock_release] mock_queryset.filter.assert_called_with(repository__organization__login="owasp") - @patch("apps.github.api.internal.queries.release.Subquery") @patch("apps.github.models.release.Release.objects") - def test_recent_releases_distinct( - self, mock_objects, mock_subquery, mock_queryset, mock_release - ): - """Test distinct filtering with Subquery for releases.""" + def test_recent_releases_distinct(self, mock_objects, mock_queryset, mock_release): + """Test distinct filtering with Window/Rank for releases.""" mock_objects.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_release] - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - result = ReleaseQuery().recent_releases(distinct=True) assert result == [mock_release] + mock_queryset.annotate.assert_called_once() mock_queryset.filter.assert_called() - mock_subquery.assert_called_once() + assert mock_queryset.order_by.call_count == 2 # Once initially, once after filter @patch("apps.github.models.release.Release.objects") def test_recent_releases_with_limit(self, mock_objects, mock_queryset, mock_release): @@ -103,118 +95,73 @@ def test_recent_releases_with_limit(self, mock_objects, mock_queryset, mock_rele def test_recent_releases_multiple_filters(self, mock_objects, mock_queryset, mock_release): """Test releases with multiple filters applied.""" mock_objects.filter.return_value = mock_queryset - mock_queryset.select_related.return_value = mock_queryset - mock_queryset.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_release] result = ReleaseQuery().recent_releases(login="alice", organization="owasp", limit=2) assert result == [mock_release] - mock_queryset.select_related.assert_called_once_with( - "author", "repository", "repository__organization" - ) # Verify both filters were applied filter_calls = mock_queryset.filter.call_args_list assert len(filter_calls) >= 2 mock_queryset.__getitem__.assert_called_with(slice(None, 2)) - @patch("apps.github.api.internal.queries.release.OuterRef") - @patch("apps.github.api.internal.queries.release.Subquery") @patch("apps.github.models.release.Release.objects") - def test_recent_releases_distinct_with_login( - self, mock_objects, mock_subquery, mock_outer_ref, mock_queryset, mock_release - ): + def test_recent_releases_distinct_with_login(self, mock_objects, mock_queryset, mock_release): """Test distinct filtering with login filter.""" mock_objects.filter.return_value = mock_queryset - mock_queryset.select_related.return_value = mock_queryset - mock_queryset.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_release] - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - mock_outer_ref_instance = Mock() - mock_outer_ref.return_value = mock_outer_ref_instance - result = ReleaseQuery().recent_releases(distinct=True, login="alice") assert result == [mock_release] - mock_subquery.assert_called_once() - mock_outer_ref.assert_called_once_with("author_id") - mock_queryset.select_related.assert_called_once_with( - "author", "repository", "repository__organization" - ) + mock_queryset.annotate.assert_called_once() + # One filter for login, one for rank=1 + # (initial filter is on Release.objects, not mock_queryset) + assert mock_queryset.filter.call_count == 2 - @patch("apps.github.api.internal.queries.release.OuterRef") - @patch("apps.github.api.internal.queries.release.Subquery") @patch("apps.github.models.release.Release.objects") def test_recent_releases_distinct_with_organization( - self, mock_objects, mock_subquery, mock_outer_ref, mock_queryset, mock_release + self, mock_objects, mock_queryset, mock_release ): """Test distinct filtering with organization filter.""" mock_objects.filter.return_value = mock_queryset - mock_queryset.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_release] - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - mock_outer_ref_instance = Mock() - mock_outer_ref.return_value = mock_outer_ref_instance - result = ReleaseQuery().recent_releases(distinct=True, organization="owasp") assert result == [mock_release] - mock_subquery.assert_called_once() - mock_outer_ref.assert_called_once_with("author_id") - # Verify organization filter was applied - filter_calls = mock_queryset.filter.call_args_list - organization_filter_found = any( - "repository__organization__login" in str(call) for call in filter_calls - ) - assert organization_filter_found, "Organization filter should be applied" + mock_queryset.annotate.assert_called_once() + # One filter for organization, one for rank=1 + # (initial filter is on Release.objects not mock_queryset) + assert mock_queryset.filter.call_count == 2 - @patch("apps.github.api.internal.queries.release.OuterRef") - @patch("apps.github.api.internal.queries.release.Subquery") @patch("apps.github.models.release.Release.objects") def test_recent_releases_distinct_with_all_filters( - self, mock_objects, mock_subquery, mock_outer_ref, mock_queryset, mock_release + self, mock_objects, mock_queryset, mock_release ): """Test distinct filtering with all filters.""" mock_objects.filter.return_value = mock_queryset - mock_queryset.select_related.return_value = mock_queryset - mock_queryset.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_release] - mock_subquery_instance = Mock() - mock_subquery.return_value = mock_subquery_instance - mock_outer_ref_instance = Mock() - mock_outer_ref.return_value = mock_outer_ref_instance - result = ReleaseQuery().recent_releases( distinct=True, login="alice", organization="owasp", limit=5 ) assert result == [mock_release] - mock_subquery.assert_called_once() - mock_outer_ref.assert_called_once_with("author_id") - mock_queryset.select_related.assert_called_once_with( - "author", "repository", "repository__organization" - ) - # Verify both login and organization filters were applied - assert mock_queryset.filter.call_count >= 3 # base filter, login, organization + mock_queryset.annotate.assert_called_once() + # One filter for initial filters (is_draft, is_pre_release, published_at), + # one for login, one for organization, one for rank=1 + # Note: There's also filter() calls for login and organization parameters + mock_queryset.__getitem__.assert_called_with(slice(None, 5)) @patch("apps.github.models.release.Release.objects") def test_recent_releases_login_only(self, mock_objects, mock_queryset, mock_release): """Test filtering releases by login only.""" mock_objects.filter.return_value = mock_queryset - mock_queryset.select_related.return_value = mock_queryset - mock_queryset.filter.return_value = mock_queryset mock_queryset.__getitem__.return_value = [mock_release] result = ReleaseQuery().recent_releases(login="alice") assert result == [mock_release] - mock_queryset.select_related.assert_called_once_with( - "author", "repository", "repository__organization" - ) # Verify login filter was applied assert len(mock_queryset.filter.call_args_list) >= 1 diff --git a/backend/tests/apps/github/api/internal/queries/repository_test.py b/backend/tests/apps/github/api/internal/queries/repository_test.py index f8bb8213c2..3ef0029d97 100644 --- a/backend/tests/apps/github/api/internal/queries/repository_test.py +++ b/backend/tests/apps/github/api/internal/queries/repository_test.py @@ -21,19 +21,20 @@ def mock_repository(self): def test_resolve_repository_existing(self, mock_repository): """Test resolving an existing repository.""" mock_queryset = MagicMock() + mock_queryset.select_related.return_value = mock_queryset mock_queryset.get.return_value = mock_repository with patch( - "apps.github.models.repository.Repository.objects.select_related", - return_value=mock_queryset, - ) as mock_select_related: + "apps.github.models.repository.Repository.objects", + mock_queryset, + ): result = RepositoryQuery().repository( organization_key="test-org", repository_key="test-repo", ) assert result == mock_repository - mock_select_related.assert_called_once_with("organization") + mock_queryset.select_related.assert_called_once_with("organization") mock_queryset.get.assert_called_once_with( organization__login__iexact="test-org", key__iexact="test-repo", @@ -42,19 +43,20 @@ def test_resolve_repository_existing(self, mock_repository): def test_resolve_repository_not_found(self): """Test resolving a non-existent repository.""" mock_queryset = MagicMock() + mock_queryset.select_related.return_value = mock_queryset mock_queryset.get.side_effect = Repository.DoesNotExist with patch( - "apps.github.models.repository.Repository.objects.select_related", - return_value=mock_queryset, - ) as mock_select_related: + "apps.github.models.repository.Repository.objects", + mock_queryset, + ): result = RepositoryQuery().repository( organization_key="non-existent-org", repository_key="non-existent-repo", ) assert result is None - mock_select_related.assert_called_once_with("organization") + mock_queryset.select_related.assert_called_once_with("organization") mock_queryset.get.assert_called_once_with( organization__login__iexact="non-existent-org", key__iexact="non-existent-repo", @@ -68,9 +70,9 @@ def test_resolve_repositories(self, mock_repository): ] with patch( - "apps.github.models.repository.Repository.objects.select_related", - return_value=mock_queryset, - ) as mock_select_related: + "apps.github.models.repository.Repository.objects", + mock_queryset, + ): result = RepositoryQuery().repositories( organization="test-org", limit=1, @@ -78,6 +80,5 @@ def test_resolve_repositories(self, mock_repository): assert isinstance(result, list) assert result[0] == mock_repository - mock_select_related.assert_called_once_with("organization") mock_queryset.filter.assert_called_once_with(organization__login__iexact="test-org") mock_queryset.filter.return_value.order_by.assert_called_once_with("-stars_count") diff --git a/backend/tests/apps/github/models/repository_contributor_test.py b/backend/tests/apps/github/models/repository_contributor_test.py index 1a1d47185d..204750e1bb 100644 --- a/backend/tests/apps/github/models/repository_contributor_test.py +++ b/backend/tests/apps/github/models/repository_contributor_test.py @@ -220,9 +220,10 @@ def test_get_top_contributors_data_processing(self): expected = [ { "avatar_url": "url1", + "contributions_count": 100, + "id": "user1", "login": "user1", "name": "User One", - "contributions_count": 100, } ] assert result == expected diff --git a/backend/tests/apps/github/models/repository_test.py b/backend/tests/apps/github/models/repository_test.py index 07ee59ed01..4ffefc414f 100644 --- a/backend/tests/apps/github/models/repository_test.py +++ b/backend/tests/apps/github/models/repository_test.py @@ -298,17 +298,18 @@ def test_published_releases(self, repository_setup): is_draft=False, is_pre_release=False, published_at__isnull=False ) - def test_recent_milestones(self, mocker, repository_setup): + def test_recent_milestones(self, repository_setup): """Test the recent_milestones property to ensure it returns milestones. ordered by creation date. """ _owner, repository = repository_setup - mock_filter = mocker.patch("apps.github.models.repository.Milestone.objects.filter") - mock_filter.return_value.order_by.return_value = "recent_milestones" - assert repository.recent_milestones == "recent_milestones" - mock_filter.assert_called_with(repository=repository) - mock_filter.return_value.order_by.assert_called_with("-created_at") + with patch.object(Repository, "milestones", new_callable=PropertyMock) as mock_prop: + mock_manager = mock_prop.return_value + mock_manager.order_by.return_value = "recent_milestones" + repository.pk = 123 + assert repository.recent_milestones == "recent_milestones" + mock_manager.order_by.assert_called_with("-created_at") def test_top_languages(self, repository_setup): """Test the top_languages property to ensure it returns a sorted list of. diff --git a/backend/tests/apps/owasp/api/internal/nodes/board_of_directors_test.py b/backend/tests/apps/owasp/api/internal/nodes/board_of_directors_test.py index 6139ac3a5f..6a508e9bee 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/board_of_directors_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/board_of_directors_test.py @@ -3,27 +3,34 @@ from unittest.mock import Mock from apps.owasp.api.internal.nodes.board_of_directors import BoardOfDirectorsNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestBoardOfDirectorsNode: - def test_node_fields(self): - node = BoardOfDirectorsNode.__strawberry_definition__ - - field_names = {field.name for field in node.fields} +class TestBoardOfDirectorsNode(GraphQLNodeBaseTest): + """Test cases for BoardOfDirectorsNode class.""" - assert "year" in field_names - assert "created_at" in field_names - assert "updated_at" in field_names - assert "owasp_url" in field_names - assert "candidates" in field_names - assert "members" in field_names + def test_node_fields(self): + field_names = { + field.name for field in BoardOfDirectorsNode.__strawberry_definition__.fields + } + expected_field_names = { + "_id", + "candidates", + "created_at", + "members", + "owasp_url", + "updated_at", + "year", + } + assert field_names == expected_field_names def test_owasp_url_resolver(self): """Test owasp_url returns URL from board instance.""" mock_board = Mock() mock_board.owasp_url = "https://board.owasp.org/elections/2025_elections" - result = BoardOfDirectorsNode.owasp_url(mock_board) + field = self._get_field_by_name("owasp_url", BoardOfDirectorsNode) + result = field.base_resolver.wrapped_func(None, mock_board) assert result == "https://board.owasp.org/elections/2025_elections" @@ -35,7 +42,8 @@ def test_candidates_resolver(self): mock_board = Mock() mock_board.get_candidates.return_value = [mock_candidate1, mock_candidate2] - result = BoardOfDirectorsNode.candidates(mock_board) + field = self._get_field_by_name("candidates", BoardOfDirectorsNode) + result = field.base_resolver.wrapped_func(None, mock_board) assert result == [mock_candidate1, mock_candidate2] mock_board.get_candidates.assert_called_once() @@ -48,7 +56,8 @@ def test_members_resolver(self): mock_board = Mock() mock_board.get_members.return_value = [mock_member1, mock_member2] - result = BoardOfDirectorsNode.members(mock_board) + field = self._get_field_by_name("members", BoardOfDirectorsNode) + result = field.base_resolver.wrapped_func(None, mock_board) assert result == [mock_member1, mock_member2] mock_board.get_members.assert_called_once() diff --git a/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py index f05abd9b80..3b7cb91027 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py @@ -1,9 +1,10 @@ """Test cases for ChapterNode.""" from apps.owasp.api.internal.nodes.chapter import ChapterNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestChapterNode: +class TestChapterNode(GraphQLNodeBaseTest): def test_chapter_node_inheritance(self): assert hasattr(ChapterNode, "__strawberry_definition__") @@ -27,38 +28,33 @@ def test_meta_configuration(self): } assert expected_field_names.issubset(field_names) - def _get_field_by_name(self, name): - return next( - (f for f in ChapterNode.__strawberry_definition__.fields if f.name == name), None - ) - def test_resolve_key(self): - field = self._get_field_by_name("key") + field = self._get_field_by_name("key", ChapterNode) assert field is not None assert field.type is str def test_resolve_country(self): - field = self._get_field_by_name("country") + field = self._get_field_by_name("country", ChapterNode) assert field is not None assert field.type is str def test_resolve_region(self): - field = self._get_field_by_name("region") + field = self._get_field_by_name("region", ChapterNode) assert field is not None assert field.type is str def test_resolve_is_active(self): - field = self._get_field_by_name("is_active") + field = self._get_field_by_name("is_active", ChapterNode) assert field is not None assert field.type is bool def test_resolve_contribution_data(self): - field = self._get_field_by_name("contribution_data") + field = self._get_field_by_name("contribution_data", ChapterNode) assert field is not None assert field.type.__class__.__name__ == "NewType" def test_resolve_contribution_stats(self): - field = self._get_field_by_name("contribution_stats") + field = self._get_field_by_name("contribution_stats", ChapterNode) assert field is not None assert field.type.__class__.__name__ == "StrawberryOptional" @@ -78,10 +74,8 @@ def test_contribution_stats_transforms_snake_case_to_camel_case(self): instance = type("BoundNode", (), {})() instance.contribution_stats = mock_chapter.contribution_stats - field = self._get_field_by_name("contribution_stats") - resolver = field.base_resolver.wrapped_func - - result = resolver(instance) + field = self._get_field_by_name("contribution_stats", ChapterNode) + result = field.base_resolver.wrapped_func(None, instance) assert result is not None assert result["commits"] == 75 diff --git a/backend/tests/apps/owasp/api/internal/nodes/committee_test.py b/backend/tests/apps/owasp/api/internal/nodes/committee_test.py index 8596311673..081348e9b4 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/committee_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/committee_test.py @@ -4,9 +4,10 @@ from unittest.mock import Mock from apps.owasp.api.internal.nodes.committee import CommitteeNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestCommitteeNode: +class TestCommitteeNode(GraphQLNodeBaseTest): def test_contributors_count_resolver(self): """Test contributors_count returns count from repository.""" mock_repo = Mock() @@ -15,7 +16,8 @@ def test_contributors_count_resolver(self): mock_committee = Mock() mock_committee.owasp_repository = mock_repo - result = CommitteeNode.contributors_count(mock_committee) + field = self._get_field_by_name("contributors_count", CommitteeNode) + result = field.base_resolver.wrapped_func(None, mock_committee) assert result == 42 @@ -24,7 +26,8 @@ def test_created_at_resolver(self): mock_committee = Mock() mock_committee.idx_created_at = 1234567890.0 - result = CommitteeNode.created_at(mock_committee) + field = self._get_field_by_name("created_at", CommitteeNode) + result = field.base_resolver.wrapped_func(None, mock_committee) assert math.isclose(result, 1234567890.0) @@ -36,7 +39,8 @@ def test_forks_count_resolver(self): mock_committee = Mock() mock_committee.owasp_repository = mock_repo - result = CommitteeNode.forks_count(mock_committee) + field = self._get_field_by_name("forks_count", CommitteeNode) + result = field.base_resolver.wrapped_func(None, mock_committee) assert result == 15 @@ -48,7 +52,8 @@ def test_issues_count_resolver(self): mock_committee = Mock() mock_committee.owasp_repository = mock_repo - result = CommitteeNode.issues_count(mock_committee) + field = self._get_field_by_name("issues_count", CommitteeNode) + result = field.base_resolver.wrapped_func(None, mock_committee) assert result == 23 @@ -56,7 +61,8 @@ def test_repositories_count_resolver(self): """Test repositories_count always returns 1 for committees.""" mock_committee = Mock() - result = CommitteeNode.repositories_count(mock_committee) + field = self._get_field_by_name("repositories_count", CommitteeNode) + result = field.base_resolver.wrapped_func(None, mock_committee) assert result == 1 @@ -68,6 +74,7 @@ def test_stars_count_resolver(self): mock_committee = Mock() mock_committee.owasp_repository = mock_repo - result = CommitteeNode.stars_count(mock_committee) + field = self._get_field_by_name("stars_count", CommitteeNode) + result = field.base_resolver.wrapped_func(None, mock_committee) assert result == 100 diff --git a/backend/tests/apps/owasp/api/internal/nodes/common_test.py b/backend/tests/apps/owasp/api/internal/nodes/common_test.py index 4a9d18e0f2..0592ea71ff 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/common_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/common_test.py @@ -15,7 +15,7 @@ def test_entity_leaders_resolver(self): mock_entity = Mock() mock_entity.entity_leaders = [mock_leader1, mock_leader2] - result = GenericEntityNode.entity_leaders(mock_entity) + result = GenericEntityNode().entity_leaders(mock_entity) assert result == [mock_leader1, mock_leader2] @@ -24,16 +24,16 @@ def test_leaders_resolver(self): mock_entity = Mock() mock_entity.idx_leaders = ["leader1", "leader2"] - result = GenericEntityNode.leaders(mock_entity) + result = GenericEntityNode.leaders(None, mock_entity) assert result == ["leader1", "leader2"] def test_related_urls_resolver(self): - """Test related_urls returns indexed URLs list.""" + """Test related_urls returns URLs list.""" mock_entity = Mock() - mock_entity.idx_related_urls = ["https://example.com", "https://test.com"] + mock_entity.related_urls = ["https://example.com", "https://test.com"] - result = GenericEntityNode.related_urls(mock_entity) + result = GenericEntityNode.related_urls(None, mock_entity) assert result == ["https://example.com", "https://test.com"] @@ -42,7 +42,7 @@ def test_updated_at_resolver(self): mock_entity = Mock() mock_entity.idx_updated_at = 1234567890.0 - result = GenericEntityNode.updated_at(mock_entity) + result = GenericEntityNode.updated_at(None, mock_entity) assert math.isclose(result, 1234567890.0) @@ -51,6 +51,6 @@ def test_url_resolver(self): mock_entity = Mock() mock_entity.idx_url = "https://owasp.org/www-project-example" - result = GenericEntityNode.url(mock_entity) + result = GenericEntityNode.url(None, mock_entity) assert result == "https://owasp.org/www-project-example" diff --git a/backend/tests/apps/owasp/api/internal/nodes/member_snapshot_test.py b/backend/tests/apps/owasp/api/internal/nodes/member_snapshot_test.py index bbca8651d2..33abbc4829 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/member_snapshot_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/member_snapshot_test.py @@ -3,9 +3,10 @@ from unittest.mock import Mock from apps.owasp.api.internal.nodes.member_snapshot import MemberSnapshotNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestMemberSnapshotNode: +class TestMemberSnapshotNode(GraphQLNodeBaseTest): def test_node_fields(self): mock_snapshot = Mock() mock_snapshot.start_at = "2025-01-01" @@ -31,7 +32,8 @@ def test_commits_count_resolver(self): mock_snapshot = Mock() mock_snapshot.commits_count = 42 - result = MemberSnapshotNode.commits_count(mock_snapshot) + field = self._get_field_by_name("commits_count", MemberSnapshotNode) + result = field.base_resolver.wrapped_func(None, mock_snapshot) assert result == 42 @@ -41,7 +43,8 @@ def test_github_user_resolver(self): mock_snapshot = Mock() mock_snapshot.github_user = mock_user - result = MemberSnapshotNode.github_user(mock_snapshot) + field = self._get_field_by_name("github_user", MemberSnapshotNode) + result = field.base_resolver.wrapped_func(None, mock_snapshot) assert result == mock_user @@ -50,7 +53,8 @@ def test_issues_count_resolver(self): mock_snapshot = Mock() mock_snapshot.issues_count = 15 - result = MemberSnapshotNode.issues_count(mock_snapshot) + field = self._get_field_by_name("issues_count", MemberSnapshotNode) + result = field.base_resolver.wrapped_func(None, mock_snapshot) assert result == 15 @@ -59,7 +63,8 @@ def test_pull_requests_count_resolver(self): mock_snapshot = Mock() mock_snapshot.pull_requests_count = 23 - result = MemberSnapshotNode.pull_requests_count(mock_snapshot) + field = self._get_field_by_name("pull_requests_count", MemberSnapshotNode) + result = field.base_resolver.wrapped_func(None, mock_snapshot) assert result == 23 @@ -68,7 +73,8 @@ def test_messages_count_resolver(self): mock_snapshot = Mock() mock_snapshot.messages_count = 100 - result = MemberSnapshotNode.messages_count(mock_snapshot) + field = self._get_field_by_name("messages_count", MemberSnapshotNode) + result = field.base_resolver.wrapped_func(None, mock_snapshot) assert result == 100 @@ -77,6 +83,7 @@ def test_total_contributions_resolver(self): mock_snapshot = Mock() mock_snapshot.total_contributions = 80 - result = MemberSnapshotNode.total_contributions(mock_snapshot) + field = self._get_field_by_name("total_contributions", MemberSnapshotNode) + result = field.base_resolver.wrapped_func(None, mock_snapshot) assert result == 80 diff --git a/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py b/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py index 03f3f672e8..4f282b4c18 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/project_health_metrics_test.py @@ -5,9 +5,10 @@ import pytest from apps.owasp.api.internal.nodes.project_health_metrics import ProjectHealthMetricsNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestProjectHealthMetricsNode: +class TestProjectHealthMetricsNode(GraphQLNodeBaseTest): def test_project_health_metrics_node_inheritance(self): assert hasattr(ProjectHealthMetricsNode, "__strawberry_definition__") @@ -42,16 +43,6 @@ def test_meta_configuration(self): } assert expected_field_names.issubset(field_names) - def _get_field_by_name(self, name): - return next( - ( - f - for f in ProjectHealthMetricsNode.__strawberry_definition__.fields - if f.name == name - ), - None, - ) - @pytest.mark.parametrize( ("field_name", "expected_type"), [ @@ -80,6 +71,6 @@ def _get_field_by_name(self, name): ], ) def test_field_types(self, field_name, expected_type): - field = self._get_field_by_name(field_name) + field = self._get_field_by_name(field_name, ProjectHealthMetricsNode) assert field is not None assert field.type is expected_type diff --git a/backend/tests/apps/owasp/api/internal/nodes/project_health_stats_test.py b/backend/tests/apps/owasp/api/internal/nodes/project_health_stats_test.py index 3bcd9c1caa..28bd26c81a 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/project_health_stats_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/project_health_stats_test.py @@ -6,9 +6,10 @@ from strawberry.types.base import StrawberryList from apps.owasp.api.internal.nodes.project_health_stats import ProjectHealthStatsNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestHealthStatsNode: +class TestHealthStatsNode(GraphQLNodeBaseTest): def test_project_health_stats_node_inheritance(self): assert hasattr(ProjectHealthStatsNode, "__strawberry_definition__") @@ -32,20 +33,9 @@ def test_meta_configuration(self): } assert expected_field_names == field_names - def _get_field_by_name(self, name): - return next( - ( - field - for field in ProjectHealthStatsNode.__strawberry_definition__.fields - if field.name == name - ), - None, - ) - @pytest.mark.parametrize( ("field_name", "expected_type"), [ - ("average_score", float), ("monthly_overall_scores", list[float]), ("monthly_overall_scores_months", list[int]), ("projects_count_healthy", int), @@ -60,7 +50,7 @@ def _get_field_by_name(self, name): ], ) def test_field_types(self, field_name, expected_type): - field = self._get_field_by_name(field_name) + field = self._get_field_by_name(field_name, ProjectHealthStatsNode) assert field is not None, f"Field {field_name} not found in HealthStatsNode." origin = get_origin(expected_type) diff --git a/backend/tests/apps/owasp/api/internal/nodes/project_test.py b/backend/tests/apps/owasp/api/internal/nodes/project_test.py index d47cb64287..4a57206e31 100644 --- a/backend/tests/apps/owasp/api/internal/nodes/project_test.py +++ b/backend/tests/apps/owasp/api/internal/nodes/project_test.py @@ -7,9 +7,10 @@ from apps.github.api.internal.nodes.repository import RepositoryNode from apps.owasp.api.internal.nodes.project import ProjectNode from apps.owasp.api.internal.nodes.project_health_metrics import ProjectHealthMetricsNode +from tests.apps.common.graphql_node_base_test import GraphQLNodeBaseTest -class TestProjectNode: +class TestProjectNode(GraphQLNodeBaseTest): def test_project_node_inheritance(self): assert hasattr(ProjectNode, "__strawberry_definition__") @@ -41,78 +42,73 @@ def test_meta_configuration(self): } assert expected_field_names.issubset(field_names) - def _get_field_by_name(self, name): - return next( - (f for f in ProjectNode.__strawberry_definition__.fields if f.name == name), None - ) - def test_resolve_health_metrics_latest(self): - field = self._get_field_by_name("health_metrics_latest") + field = self._get_field_by_name("health_metrics_latest", ProjectNode) assert field is not None assert field.type.of_type is ProjectHealthMetricsNode def test_resolve_health_metrics_list(self): - field = self._get_field_by_name("health_metrics_list") + field = self._get_field_by_name("health_metrics_list", ProjectNode) assert field is not None assert field.type.of_type is ProjectHealthMetricsNode def test_resolve_issues_count(self): - field = self._get_field_by_name("issues_count") + field = self._get_field_by_name("issues_count", ProjectNode) assert field is not None assert field.type is int def test_resolve_key(self): - field = self._get_field_by_name("key") + field = self._get_field_by_name("key", ProjectNode) assert field is not None assert field.type is str def test_resolve_languages(self): - field = self._get_field_by_name("languages") + field = self._get_field_by_name("languages", ProjectNode) assert field is not None assert field.type == list[str] def test_resolve_recent_issues(self): - field = self._get_field_by_name("recent_issues") + field = self._get_field_by_name("recent_issues", ProjectNode) assert field is not None assert field.type.of_type is IssueNode def test_resolve_recent_milestones(self): - field = self._get_field_by_name("recent_milestones") + field = self._get_field_by_name("recent_milestones", ProjectNode) assert field is not None assert field.type.of_type is MilestoneNode def test_resolve_recent_pull_requests(self): - field = self._get_field_by_name("recent_pull_requests") + field = self._get_field_by_name("recent_pull_requests", ProjectNode) assert field is not None assert field.type.of_type is PullRequestNode def test_resolve_recent_releases(self): - field = self._get_field_by_name("recent_releases") + field = self._get_field_by_name("recent_releases", ProjectNode) assert field is not None assert field.type.of_type is ReleaseNode def test_resolve_repositories(self): - field = self._get_field_by_name("repositories") + field = self._get_field_by_name("repositories", ProjectNode) assert field is not None assert field.type.of_type is RepositoryNode def test_resolve_repositories_count(self): - field = self._get_field_by_name("repositories_count") + field = self._get_field_by_name("repositories_count", ProjectNode) assert field is not None assert field.type is int def test_resolve_topics(self): - field = self._get_field_by_name("topics") + field = self._get_field_by_name("topics", ProjectNode) assert field is not None assert field.type == list[str] def test_resolve_contribution_stats(self): - field = self._get_field_by_name("contribution_stats") + field = self._get_field_by_name("contribution_stats", ProjectNode) assert field is not None assert field.type.__class__.__name__ == "StrawberryOptional" def test_resolve_contribution_data(self): - field = self._get_field_by_name("contribution_data") + field = self._get_field_by_name("contribution_data", ProjectNode) assert field is not None assert field.type.__class__.__name__ == "StrawberryOptional" @@ -132,10 +128,8 @@ def test_contribution_stats_transforms_snake_case_to_camel_case(self): instance = type("BoundNode", (), {})() instance.contribution_stats = mock_project.contribution_stats - field = self._get_field_by_name("contribution_stats") - resolver = field.base_resolver.wrapped_func - - result = resolver(instance) + field = self._get_field_by_name("contribution_stats", ProjectNode) + result = field.base_resolver.wrapped_func(None, instance) assert result is not None assert result["commits"] == 100 diff --git a/backend/tests/apps/owasp/api/internal/queries/member_snapshot_test.py b/backend/tests/apps/owasp/api/internal/queries/member_snapshot_test.py index ece3e985bf..ad87ae500b 100644 --- a/backend/tests/apps/owasp/api/internal/queries/member_snapshot_test.py +++ b/backend/tests/apps/owasp/api/internal/queries/member_snapshot_test.py @@ -33,17 +33,21 @@ def test_member_snapshot_by_user_and_year(self): mock_user_cls.objects.get.return_value = mock_user mock_queryset = Mock() + mock_queryset.select_related.return_value = mock_queryset + mock_queryset.prefetch_related.return_value = mock_queryset mock_queryset.filter.return_value = mock_queryset mock_queryset.order_by.return_value = mock_queryset mock_queryset.first.return_value = mock_snapshot - mock_snapshot_cls.objects.filter.return_value = mock_queryset + mock_snapshot_cls.objects = mock_queryset result = query.member_snapshot(user_login="testuser", start_year=2025) mock_user_cls.objects.get.assert_called_once_with(login="testuser") - mock_snapshot_cls.objects.filter.assert_called_once_with(github_user=mock_user) - mock_queryset.filter.assert_called_once_with(start_at__year=2025) + # select_related and prefetch_related are called, then filter twice + mock_queryset.select_related.assert_called_once() + mock_queryset.prefetch_related.assert_called_once() + assert mock_queryset.filter.call_count == 2 assert result == mock_snapshot def test_member_snapshots_all(self): @@ -55,6 +59,7 @@ def test_member_snapshots_all(self): "apps.owasp.api.internal.queries.member_snapshot.MemberSnapshot" ) as mock_snapshot_cls: mock_queryset = Mock() + mock_queryset.select_related.return_value = mock_queryset mock_queryset.order_by.return_value = mock_queryset mock_queryset.__getitem__ = Mock(return_value=mock_snapshots) @@ -63,6 +68,7 @@ def test_member_snapshots_all(self): result = query.member_snapshots(limit=10) mock_snapshot_cls.objects.all.assert_called_once() + mock_queryset.select_related.assert_called_once_with("github_user") mock_queryset.order_by.assert_called_once_with("-start_at") mock_queryset.__getitem__.assert_called_once_with(slice(None, 10, None)) assert result == mock_snapshots @@ -82,6 +88,7 @@ def test_member_snapshots_by_user(self): mock_user_cls.objects.get.return_value = mock_user mock_queryset = Mock() + mock_queryset.select_related.return_value = mock_queryset mock_queryset.filter.return_value = mock_queryset mock_queryset.order_by.return_value = mock_queryset mock_queryset.__getitem__ = Mock(return_value=mock_snapshots) @@ -91,6 +98,7 @@ def test_member_snapshots_by_user(self): result = query.member_snapshots(user_login="testuser", limit=5) mock_user_cls.objects.get.assert_called_once_with(login="testuser") + mock_queryset.select_related.assert_called_once_with("github_user") mock_queryset.filter.assert_called_once_with(github_user=mock_user) assert result == mock_snapshots diff --git a/backend/tests/apps/owasp/models/chapter_test.py b/backend/tests/apps/owasp/models/chapter_test.py index 92e62af8fd..0c09445a7e 100644 --- a/backend/tests/apps/owasp/models/chapter_test.py +++ b/backend/tests/apps/owasp/models/chapter_test.py @@ -113,10 +113,15 @@ def test_str_representation(self, name, key, expected_str): ], ) def test_active_chapters_count(self, value): - with patch("apps.common.index.IndexBase.get_total_count") as mock_count: - mock_count.return_value = value + with patch.object(Chapter.objects, "filter") as mock_filter: + mock_filter.return_value.count.return_value = value assert Chapter.active_chapters_count() == value - mock_count.assert_called_once_with("chapters", search_filters="idx_is_active:true") + mock_filter.assert_called_once_with( + is_active=True, + latitude__isnull=False, + longitude__isnull=False, + owasp_repository__is_empty=False, + ) @pytest.mark.parametrize( ("has_suggested_location", "has_coordinates"), diff --git a/backend/tests/apps/owasp/models/committee_test.py b/backend/tests/apps/owasp/models/committee_test.py index 59454e3468..dd006c50d0 100644 --- a/backend/tests/apps/owasp/models/committee_test.py +++ b/backend/tests/apps/owasp/models/committee_test.py @@ -2,7 +2,6 @@ import pytest -from apps.common.index import IndexBase from apps.github.models.repository import Repository from apps.github.models.user import User from apps.owasp.models.committee import Committee @@ -27,10 +26,11 @@ def test_str_representation(self, name, expected_str): ], ) def test_active_committees_count(self, value): - with patch.object(IndexBase, "get_total_count", return_value=value) as mock_count: + with patch.object(Committee.objects, "filter") as mock_filter: + mock_filter.return_value.count.return_value = value count = Committee.active_committees_count() assert count == value - mock_count.assert_called_once_with("committees") + mock_filter.assert_called_once_with(has_active_repositories=True) @pytest.mark.parametrize( ("summary"), diff --git a/backend/tests/fuzz/__init__.py b/backend/tests/fuzz/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/fuzz/graphql_test.py b/backend/tests/fuzz/graphql_test.py new file mode 100644 index 0000000000..f006395591 --- /dev/null +++ b/backend/tests/fuzz/graphql_test.py @@ -0,0 +1,33 @@ +"""OWASP Nest GraphQL API fuzz tests.""" + +import logging +import os +from contextlib import suppress + +import schemathesis +from schemathesis.graphql.checks import GraphQLClientError + +if not (BASE_URL := os.getenv("BASE_URL")) or not (CSRF_TOKEN := os.getenv("CSRF_TOKEN")): + message = "BASE_URL and CSRF_TOKEN must be set in the environment." + raise ValueError(message) + +HEADERS = { + "Cookie": f"csrftoken={CSRF_TOKEN}", + "X-CSRFToken": CSRF_TOKEN, +} + +logger = logging.getLogger(__name__) +schema = schemathesis.graphql.from_url( + f"{BASE_URL}/graphql/", + headers=HEADERS, + timeout=30, + wait_for_schema=10, +) + + +@schema.parametrize() +def test_graphql_api(case: schemathesis.Case) -> None: + """Test GraphQL API endpoints.""" + logger.info(case.as_curl_command()) + with suppress(GraphQLClientError): + case.call_and_validate(headers=HEADERS) diff --git a/backend/tests/fuzz/rest_test.py b/backend/tests/fuzz/rest_test.py new file mode 100644 index 0000000000..bd03dc976b --- /dev/null +++ b/backend/tests/fuzz/rest_test.py @@ -0,0 +1,30 @@ +"""OWASP Nest REST API fuzz tests.""" + +import logging +import os + +import schemathesis + +if not (REST_URL := os.getenv("REST_URL")) or not (CSRF_TOKEN := os.getenv("CSRF_TOKEN")): + message = "REST_URL and CSRF_TOKEN must be set in the environment." + raise ValueError(message) + +HEADERS = { + "Cookie": f"csrftoken={CSRF_TOKEN}", + "X-CSRFToken": CSRF_TOKEN, +} + +logger = logging.getLogger(__name__) +schema = schemathesis.openapi.from_url( + f"{REST_URL}/openapi.json", + headers=HEADERS, + timeout=30, + wait_for_schema=10, +) + + +@schema.parametrize() +def test_rest_api(case): + """Test REST API endpoints.""" + logger.info(case.as_curl_command()) + case.call_and_validate(headers=HEADERS) diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index c6bffdc7bf..9cfb82a9eb 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -2,6 +2,7 @@ Agentic Agsoc Aichi Aissue +Atqc Aupdated BOTTOMPADDING CCSP @@ -20,6 +21,7 @@ NOASSERTION NOSONAR Nadu Nominatim +PGPASSWORD PLR PYTHONUNBUFFERED RUF @@ -48,6 +50,7 @@ aquasecurity arithmatex arkid15r askowasp +attisdropped bangbang bsky certbot @@ -65,6 +68,7 @@ dockerhub dsn elevenlabs env +euo facebookexternalhit gamesec geocoders @@ -72,6 +76,7 @@ geoloc geopy gha graphiql +graphqler gunicorn hackathon heroui @@ -112,7 +117,9 @@ nestbot ngx noinput nosniff +nspname numfmt +openblas openstreetmap owasppcitoolkit owtf @@ -137,6 +144,7 @@ rsc saft sakanashi samm +schemathesis seo skillstruck slackbot @@ -145,6 +153,7 @@ speakerdeck superfences tgz tiktok +trgm trivyignores tsc unassigning diff --git a/docker-compose/e2e/compose.yaml b/docker-compose/e2e/compose.yaml new file mode 100644 index 0000000000..1f808eaf09 --- /dev/null +++ b/docker-compose/e2e/compose.yaml @@ -0,0 +1,113 @@ +services: + backend: + container_name: e2e-nest-backend + command: > + sh -c ' + python manage.py migrate && + gunicorn wsgi:application --bind 0.0.0.0:9000 + ' + build: + context: ../../backend + dockerfile: ../docker/backend/Dockerfile + depends_on: + db: + condition: service_healthy + cache: + condition: service_healthy + env_file: ../../backend/.env.e2e.example + networks: + - e2e-nest-network + ports: + - 9000:9000 + healthcheck: + interval: 10s + retries: 10 + test: > + sh -c ' + wget --spider http://backend:9000/a/ + ' + timeout: 10s + start_period: 5s + + data-loader: + container_name: e2e-nest-data-loader + image: pgvector/pgvector:pg16 + depends_on: + db: + condition: service_healthy + backend: + condition: service_healthy + environment: + PGPASSWORD: ${DJANGO_DB_PASSWORD:-nest_user_e2e_password} + POSTGRES_USER: ${DJANGO_DB_USER:-nest_user_e2e} + POSTGRES_DB: ${DJANGO_DB_NAME:-nest_db_e2e} + volumes: + - ../../backend/data:/data:ro + networks: + - e2e-nest-network + command: > + sh -c ' + echo "Loading data from dump..." && + pg_restore -h db -U $$POSTGRES_USER -d $$POSTGRES_DB /data/nest.dump && + echo "Data loading completed." + ' + db: + container_name: e2e-nest-db + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: ${DJANGO_DB_NAME:-nest_db_e2e} + POSTGRES_PASSWORD: ${DJANGO_DB_PASSWORD:-nest_user_e2e_password} + POSTGRES_USER: ${DJANGO_DB_USER:-nest_user_e2e} + healthcheck: + interval: 5s + retries: 5 + test: [CMD-SHELL, pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h localhost -p 5432] + timeout: 5s + networks: + - e2e-nest-network + volumes: + - e2e-db-data:/var/lib/postgresql/data + ports: + - 5433:5432 + + cache: + command: > + sh -c ' + redis-server --requirepass $$REDIS_PASSWORD --maxmemory 100mb --maxmemory-policy allkeys-lru + ' + container_name: e2e-nest-cache + image: redis:8.0.5-alpine3.21 + environment: + REDIS_PASSWORD: ${DJANGO_REDIS_PASSWORD:-nest-cache-e2e-password} + healthcheck: + interval: 5s + retries: 5 + test: [CMD, redis-cli, -a, $$REDIS_PASSWORD, ping] + timeout: 5s + networks: + - e2e-nest-network + volumes: + - e2e-cache-data:/data + + e2e-tests: + container_name: e2e-nest-tests + build: + context: ../../frontend + dockerfile: ../docker/frontend/Dockerfile.e2e.test + command: > + sh -c ' + pnpm run test:e2e + ' + depends_on: + backend: + condition: service_healthy + env_file: ../../frontend/.env.e2e.example + networks: + - e2e-nest-network + +networks: + e2e-nest-network: + +volumes: + e2e-cache-data: + e2e-db-data: diff --git a/docker-compose/fuzz/compose.yaml b/docker-compose/fuzz/compose.yaml new file mode 100644 index 0000000000..4d5b0bdca3 --- /dev/null +++ b/docker-compose/fuzz/compose.yaml @@ -0,0 +1,126 @@ +services: + backend: + container_name: fuzz-nest-backend + command: > + sh -c ' + python manage.py migrate && + gunicorn wsgi:application --bind 0.0.0.0:9500 + ' + build: + context: ../../backend + dockerfile: ../docker/backend/Dockerfile + depends_on: + db: + condition: service_healthy + cache: + condition: service_healthy + env_file: ../../backend/.env.fuzz.example + networks: + - fuzz-nest-network + ports: + - 9500:9500 + healthcheck: + interval: 10s + retries: 10 + test: > + sh -c ' + wget --spider http://backend:9500/a/ + ' + timeout: 10s + start_period: 5s + + data-loader: + container_name: fuzz-nest-data-loader + image: pgvector/pgvector:pg16 + depends_on: + db: + condition: service_healthy + backend: + condition: service_healthy + environment: + PGPASSWORD: ${DJANGO_DB_PASSWORD:-nest_user_fuzz_password} + POSTGRES_USER: ${DJANGO_DB_USER:-nest_user_fuzz} + POSTGRES_DB: ${DJANGO_DB_NAME:-nest_db_fuzz} + volumes: + - ../../backend/data:/data:ro + networks: + - fuzz-nest-network + command: > + sh -c ' + echo "Loading data from dump..." && + pg_restore -h db -U $$POSTGRES_USER -d $$POSTGRES_DB /data/nest.dump && + echo "Data loading completed." + ' + db: + container_name: fuzz-nest-db + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: ${DJANGO_DB_NAME:-nest_db_fuzz} + POSTGRES_PASSWORD: ${DJANGO_DB_PASSWORD:-nest_user_fuzz_password} + POSTGRES_USER: ${DJANGO_DB_USER:-nest_user_fuzz} + healthcheck: + interval: 5s + retries: 5 + test: [CMD-SHELL, pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h localhost -p 5432] + timeout: 5s + networks: + - fuzz-nest-network + volumes: + - fuzz-db-data:/var/lib/postgresql/data + ports: + - 5434:5432 + + cache: + command: > + sh -c ' + redis-server --requirepass $$REDIS_PASSWORD --maxmemory 100mb --maxmemory-policy allkeys-lru + ' + container_name: fuzz-nest-cache + image: redis:8.0.5-alpine3.21 + environment: + REDIS_PASSWORD: ${DJANGO_REDIS_PASSWORD:-nest-fuzz-cache-password} + healthcheck: + interval: 5s + retries: 5 + test: [CMD, redis-cli, -a, $$REDIS_PASSWORD, ping] + timeout: 5s + networks: + - fuzz-nest-network + volumes: + - fuzz-cache-data:/data + + graphql: + container_name: fuzz-nest-graphql + build: + context: ../../backend + dockerfile: ../docker/backend/Dockerfile.fuzz + environment: + BASE_URL: http://backend:9500 + TEST_FILE: graphql_test.py + depends_on: + backend: + condition: service_healthy + networks: + - fuzz-nest-network + + rest: + container_name: fuzz-nest-rest + build: + context: ../../backend + dockerfile: ../docker/backend/Dockerfile.fuzz + environment: + BASE_URL: http://backend:9500 + REST_URL: http://backend:9500/api/v0 + TEST_FILE: rest_test.py + depends_on: + backend: + condition: service_healthy + networks: + - fuzz-nest-network + +networks: + fuzz-nest-network: + +volumes: + fuzz-cache-data: + fuzz-db-data: diff --git a/docker-compose/production/compose.yaml b/docker-compose/production/compose.yaml index ed79a179e8..fd49e69444 100644 --- a/docker-compose/production/compose.yaml +++ b/docker-compose/production/compose.yaml @@ -1,6 +1,7 @@ services: production-nest-backend: container_name: production-nest-backend + entrypoint: /home/owasp/entrypoint.sh image: owasp/nest:backend-production env_file: .env.backend depends_on: diff --git a/docker-compose/staging/compose.yaml b/docker-compose/staging/compose.yaml index b2a8161a85..051ce8433d 100644 --- a/docker-compose/staging/compose.yaml +++ b/docker-compose/staging/compose.yaml @@ -1,6 +1,7 @@ services: staging-nest-backend: container_name: staging-nest-backend + entrypoint: /home/owasp/entrypoint.sh image: owasp/nest:backend-staging env_file: .env.backend depends_on: diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index cdc3a4a6e4..6a9569c301 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -62,5 +62,3 @@ RUN rm -rf /home/owasp/.cache && \ chmod +x /home/owasp/entrypoint.sh USER owasp - -CMD ["/home/owasp/entrypoint.sh"] diff --git a/docker/backend/Dockerfile.fuzz b/docker/backend/Dockerfile.fuzz new file mode 100644 index 0000000000..75d06b16dd --- /dev/null +++ b/docker/backend/Dockerfile.fuzz @@ -0,0 +1,32 @@ +FROM python:3.13.7-alpine AS builder + +SHELL ["/bin/sh", "-o", "pipefail", "-c"] + +ENV PIP_CACHE_DIR="/home/owasp/.cache/pip" \ + PYTHONUNBUFFERED=1 \ + APK_CACHE_DIR="/home/owasp/.cache/apk-fuzz-stage" \ + APK_SYMLINK_DIR="/etc/apk/cache" \ + FORCE_COLOR=1 + +RUN mkdir -p ${APK_CACHE_DIR} && \ + ln -s ${APK_CACHE_DIR} ${APK_SYMLINK_DIR} + +RUN --mount=type=cache,target=${APK_CACHE_DIR} \ + apk update && apk upgrade && \ + apk add curl jq && \ + addgroup -S owasp && \ + adduser -S -h /home/owasp -G owasp owasp + +RUN --mount=type=cache,target=${PIP_CACHE_DIR} \ + python -m pip install --upgrade pip && \ + pip install schemathesis --cache-dir ${PIP_CACHE_DIR} + +WORKDIR /home/owasp + +COPY --chown=root:root --chmod=755 ./entrypoint.fuzz.sh ./entrypoint.sh +COPY --chown=root:root --chmod=755 tests/fuzz tests + +RUN chmod +x ./entrypoint.sh + +USER owasp +ENTRYPOINT ["./entrypoint.sh"] diff --git a/docker/backend/Dockerfile.local b/docker/backend/Dockerfile.local index ec108faaab..84994c2807 100644 --- a/docker/backend/Dockerfile.local +++ b/docker/backend/Dockerfile.local @@ -46,7 +46,7 @@ RUN mkdir -p ${APK_CACHE_DIR} && \ RUN --mount=type=cache,target=${APK_CACHE_DIR} \ apk update && apk upgrade && \ - apk add postgresql-client redis && \ + apk add postgresql16-client redis && \ addgroup -S owasp && \ adduser -S -h /home/owasp -G owasp owasp diff --git a/docker/backend/Dockerfile.test b/docker/backend/Dockerfile.test index f2b5c73256..f105bc1e2e 100644 --- a/docker/backend/Dockerfile.test +++ b/docker/backend/Dockerfile.test @@ -34,7 +34,9 @@ COPY manage.py wsgi.py ./ COPY settings settings COPY static static COPY templates templates -COPY tests tests +COPY tests/apps tests/apps +COPY tests/__init__.py tests/__init__.py +COPY tests/conftest.py tests/conftest.py FROM python:3.13.7-alpine diff --git a/frontend/.env.e2e.example b/frontend/.env.e2e.example new file mode 100644 index 0000000000..aec00ea3a7 --- /dev/null +++ b/frontend/.env.e2e.example @@ -0,0 +1,16 @@ +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000/ +NEXT_PUBLIC_API_URL=http://localhost:9000/ +NEXT_PUBLIC_CSRF_URL=http://localhost:9000/csrf/ +NEXT_PUBLIC_ENVIRONMENT=local +NEXT_PUBLIC_GRAPHQL_URL=http://localhost:9000/graphql/ +NEXT_PUBLIC_GTM_ID= +NEXT_PUBLIC_IDX_URL=http://localhost:9000/idx/ +NEXT_PUBLIC_IS_PROJECT_HEALTH_ENABLED=true +NEXT_PUBLIC_RELEASE_VERSION= +NEXT_PUBLIC_SENTRY_DSN= +NEXT_SERVER_CSRF_URL=http://localhost:9000/csrf/ +NEXT_SERVER_DISABLE_SSR=false +NEXT_SERVER_GITHUB_CLIENT_ID=your-github-client-id +NEXT_SERVER_GITHUB_CLIENT_SECRET=your-github-client-secret +NEXT_SERVER_GRAPHQL_URL=http://localhost:9000/graphql/ diff --git a/frontend/.env.example b/frontend/.env.example index 61379d4ba6..d399643ce0 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,5 @@ +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000/ NEXT_PUBLIC_API_URL=http://localhost:8000/ NEXT_PUBLIC_CSRF_URL=http://localhost:8000/csrf/ NEXT_PUBLIC_ENVIRONMENT=local @@ -12,5 +14,3 @@ NEXT_SERVER_DISABLE_SSR=false NEXT_SERVER_GITHUB_CLIENT_ID=your-github-client-id NEXT_SERVER_GITHUB_CLIENT_SECRET=your-github-client-secret NEXT_SERVER_GRAPHQL_URL=http://backend:8000/graphql/ -NEXTAUTH_SECRET= -NEXTAUTH_URL=http://localhost:3000/ diff --git a/frontend/Makefile b/frontend/Makefile index c61cd6aed7..5331db78e4 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -80,11 +80,12 @@ test-frontend-a11y: @docker run --env-file frontend/.env.example --rm nest-test-frontend-a11y pnpm run test:a11y test-frontend-e2e: - @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local docker build \ - --cache-from nest-test-frontend-e2e \ - -f docker/frontend/Dockerfile.e2e.test frontend \ - -t nest-test-frontend-e2e - @docker run --env-file frontend/.env.example --rm nest-test-frontend-e2e pnpm run test:e2e + @docker container rm -f e2e-nest-db >/dev/null 2>&1 || true + @docker volume rm -f nest-e2e_e2e-db-data >/dev/null 2>&1 || true + @DOCKER_BUILDKIT=1 \ + docker compose --project-name nest-e2e -f docker-compose/e2e/compose.yaml up --build --remove-orphans --abort-on-container-exit db cache backend data-loader + @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local \ + docker compose --project-name nest-e2e -f docker-compose/e2e/compose.yaml up --build --remove-orphans --abort-on-container-exit db cache backend e2e-tests test-frontend-unit: @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local docker build \ diff --git a/frontend/src/server/queries/homeQueries.ts b/frontend/src/server/queries/homeQueries.ts index 9b6ae361ce..73b90693f9 100644 --- a/frontend/src/server/queries/homeQueries.ts +++ b/frontend/src/server/queries/homeQueries.ts @@ -102,7 +102,7 @@ export const GET_MAIN_PAGE_DATA = gql` suggestedLocation url } - recentMilestones(limit: 5, state: "all", distinct: $distinct) { + recentMilestones(limit: 5, distinct: $distinct) { id author { id diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 5decc4b4e9..f58960d840 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -295,6 +295,11 @@ export type MilestoneNode = Node & { url: Scalars['String']['output']; }; +export enum MilestoneStateEnum { + Closed = 'CLOSED', + Open = 'OPEN' +} + export type ModuleNode = { __typename?: 'ModuleNode'; availableLabels: Array; @@ -542,7 +547,7 @@ export type ProjectHealthMetricsFilter = { DISTINCT?: InputMaybe; NOT?: InputMaybe; OR?: InputMaybe; - level?: InputMaybe; + level?: InputMaybe; score?: InputMaybe; }; @@ -589,7 +594,7 @@ export type ProjectHealthMetricsOrder = { export type ProjectHealthStatsNode = { __typename?: 'ProjectHealthStatsNode'; - averageScore: Scalars['Float']['output']; + averageScore?: Maybe; monthlyOverallScores: Array; monthlyOverallScoresMonths: Array; projectsCountHealthy: Scalars['Int']['output']; @@ -603,6 +608,14 @@ export type ProjectHealthStatsNode = { totalStars: Scalars['Int']['output']; }; +export enum ProjectLevel { + Flagship = 'FLAGSHIP', + Incubator = 'INCUBATOR', + Lab = 'LAB', + Other = 'OTHER', + Production = 'PRODUCTION' +} + export type ProjectNode = Node & { __typename?: 'ProjectNode'; contributionData?: Maybe; @@ -670,10 +683,10 @@ export type Query = { boardsOfDirectors: Array; chapter?: Maybe; committee?: Maybe; - getMenteeDetails: MenteeNode; + getMenteeDetails?: Maybe; getMenteeModuleIssues: Array; - getModule: ModuleNode; - getProgram: ProgramNode; + getModule?: Maybe; + getProgram?: Maybe; getProgramModules: Array; getProjectModules: Array; isMentor: Scalars['Boolean']['output']; @@ -834,7 +847,7 @@ export type QueryRecentMilestonesArgs = { limit?: Scalars['Int']['input']; login?: InputMaybe; organization?: InputMaybe; - state?: Scalars['String']['input']; + state?: InputMaybe; }; @@ -941,8 +954,8 @@ export type RepositoryContributorNode = { id: Scalars['ID']['output']; login: Scalars['String']['output']; name: Scalars['String']['output']; - projectKey: Scalars['String']['output']; - projectName: Scalars['String']['output']; + projectKey?: Maybe; + projectName?: Maybe; }; export type RepositoryNode = Node & { @@ -958,12 +971,11 @@ export type RepositoryNode = Node & { issues: Array; key: Scalars['String']['output']; languages: Array; - latestRelease: Scalars['String']['output']; + latestRelease?: Maybe; license: Scalars['String']['output']; name: Scalars['String']['output']; openIssuesCount: Scalars['Int']['output']; organization?: Maybe; - ownerKey: Scalars['String']['output']; project?: Maybe; recentMilestones: Array; releases: Array; @@ -992,22 +1004,16 @@ export type SnapshotNode = Node & { __typename?: 'SnapshotNode'; createdAt: Scalars['DateTime']['output']; endAt: Scalars['DateTime']['output']; - entityLeaders: Array; /** The Globally Unique ID of this object */ id: Scalars['ID']['output']; key: Scalars['String']['output']; - leaders: Array; newChapters: Array; newIssues: Array; newProjects: Array; newReleases: Array; newUsers: Array; - relatedUrls: Array; startAt: Scalars['DateTime']['output']; title: Scalars['String']['output']; - topContributors: Array; - updatedAt: Scalars['Float']['output']; - url: Scalars['String']['output']; }; export type SponsorNode = Node & { diff --git a/frontend/src/types/__generated__/homeQueries.generated.ts b/frontend/src/types/__generated__/homeQueries.generated.ts index d73b89a6d5..9189eebfb4 100644 --- a/frontend/src/types/__generated__/homeQueries.generated.ts +++ b/frontend/src/types/__generated__/homeQueries.generated.ts @@ -9,4 +9,4 @@ export type GetMainPageDataQueryVariables = Types.Exact<{ export type GetMainPageDataQuery = { recentProjects: Array<{ __typename: 'ProjectNode', id: string, createdAt: any | null, key: string, leaders: Array, name: string, openIssuesCount: number, repositoriesCount: number, type: string }>, recentPosts: Array<{ __typename: 'PostNode', id: string, authorName: string, authorImageUrl: string, publishedAt: any, title: string, url: string }>, recentChapters: Array<{ __typename: 'ChapterNode', id: string, createdAt: number, key: string, leaders: Array, name: string, suggestedLocation: string | null }>, topContributors: Array<{ __typename: 'RepositoryContributorNode', id: string, avatarUrl: string, login: string, name: string }>, recentIssues: Array<{ __typename: 'IssueNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, recentReleases: Array<{ __typename: 'ReleaseNode', id: string, name: string, organizationName: string | null, publishedAt: any | null, repositoryName: string | null, tagName: string, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }>, sponsors: Array<{ __typename: 'SponsorNode', id: string, imageUrl: string, name: string, sponsorType: string, url: string }>, statsOverview: { __typename: 'StatsNode', activeChaptersStats: number, activeProjectsStats: number, contributorsStats: number, countriesStats: number, slackWorkspaceStats: number }, upcomingEvents: Array<{ __typename: 'EventNode', id: string, category: string, endDate: any | null, key: string, name: string, startDate: any, summary: string, suggestedLocation: string, url: string }>, recentMilestones: Array<{ __typename: 'MilestoneNode', id: string, title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string, author: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }> }; -export const GetMainPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMainPageData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPosts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"6"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorImageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentChapters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"hasFullName"},"value":{"kind":"BooleanValue","value":true}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"40"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sponsors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sponsorType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"statsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeChaptersStats"}},{"kind":"Field","name":{"kind":"Name","value":"activeProjectsStats"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsStats"}},{"kind":"Field","name":{"kind":"Name","value":"countriesStats"}},{"kind":"Field","name":{"kind":"Name","value":"slackWorkspaceStats"}}]}},{"kind":"Field","name":{"kind":"Name","value":"upcomingEvents"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"9"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"state"},"value":{"kind":"StringValue","value":"all","block":false}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetMainPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMainPageData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPosts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"6"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorName"}},{"kind":"Field","name":{"kind":"Name","value":"authorImageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentChapters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"3"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"leaders"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributors"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"hasFullName"},"value":{"kind":"BooleanValue","value":true}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"40"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sponsors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sponsorType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"statsOverview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeChaptersStats"}},{"kind":"Field","name":{"kind":"Name","value":"activeProjectsStats"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsStats"}},{"kind":"Field","name":{"kind":"Name","value":"countriesStats"}},{"kind":"Field","name":{"kind":"Name","value":"slackWorkspaceStats"}}]}},{"kind":"Field","name":{"kind":"Name","value":"upcomingEvents"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"9"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"endDate"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"startDate"}},{"kind":"Field","name":{"kind":"Name","value":"summary"}},{"kind":"Field","name":{"kind":"Name","value":"suggestedLocation"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"distinct"},"value":{"kind":"Variable","name":{"kind":"Name","value":"distinct"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/issueQueries.generated.ts b/frontend/src/types/__generated__/issueQueries.generated.ts index fb77980823..e462a11f77 100644 --- a/frontend/src/types/__generated__/issueQueries.generated.ts +++ b/frontend/src/types/__generated__/issueQueries.generated.ts @@ -8,7 +8,7 @@ export type GetModuleIssueViewQueryVariables = Types.Exact<{ }>; -export type GetModuleIssueViewQuery = { getModule: { __typename: 'ModuleNode', id: string, taskDeadline: any | null, taskAssignedAt: any | null, issueByNumber: { __typename: 'IssueNode', id: string, number: number, title: string, body: string, url: string, state: string, isMerged: boolean, organizationName: string | null, repositoryName: string | null, labels: Array, assignees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }>, pullRequests: Array<{ __typename: 'PullRequestNode', id: string, title: string, url: string, state: string, createdAt: any, mergedAt: any | null, author: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string } | null }> } | null, interestedUsers: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }>, issueMentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> } }; +export type GetModuleIssueViewQuery = { getModule: { __typename: 'ModuleNode', id: string, taskDeadline: any | null, taskAssignedAt: any | null, issueByNumber: { __typename: 'IssueNode', id: string, number: number, title: string, body: string, url: string, state: string, isMerged: boolean, organizationName: string | null, repositoryName: string | null, labels: Array, assignees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }>, pullRequests: Array<{ __typename: 'PullRequestNode', id: string, title: string, url: string, state: string, createdAt: any, mergedAt: any | null, author: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string } | null }> } | null, interestedUsers: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }>, issueMentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> } | null }; export type AssignIssueToUserMutationVariables = Types.Exact<{ programKey: Types.Scalars['String']['input']; diff --git a/frontend/src/types/__generated__/menteeQueries.generated.ts b/frontend/src/types/__generated__/menteeQueries.generated.ts index 9f8dfb0c99..1b39de4367 100644 --- a/frontend/src/types/__generated__/menteeQueries.generated.ts +++ b/frontend/src/types/__generated__/menteeQueries.generated.ts @@ -8,7 +8,7 @@ export type GetModuleMenteeDetailsQueryVariables = Types.Exact<{ }>; -export type GetModuleMenteeDetailsQuery = { getMenteeDetails: { __typename: 'MenteeNode', id: string, login: string, name: string, avatarUrl: string, bio: string | null, experienceLevel: string, domains: Array | null, tags: Array | null }, getMenteeModuleIssues: Array<{ __typename: 'IssueNode', id: string, number: number, title: string, state: string, isMerged: boolean, labels: Array, createdAt: any, url: string, assignees: Array<{ __typename: 'UserNode', login: string, name: string, avatarUrl: string }> }> }; +export type GetModuleMenteeDetailsQuery = { getMenteeDetails: { __typename: 'MenteeNode', id: string, login: string, name: string, avatarUrl: string, bio: string | null, experienceLevel: string, domains: Array | null, tags: Array | null } | null, getMenteeModuleIssues: Array<{ __typename: 'IssueNode', id: string, number: number, title: string, state: string, isMerged: boolean, labels: Array, createdAt: any, url: string, assignees: Array<{ __typename: 'UserNode', login: string, name: string, avatarUrl: string }> }> }; export const GetModuleMenteeDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModuleMenteeDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMenteeDetails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"menteeKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}}]}},{"kind":"Field","name":{"kind":"Name","value":"getMenteeModuleIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"menteeKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"isMerged"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/moduleQueries.generated.ts b/frontend/src/types/__generated__/moduleQueries.generated.ts index fecc6b0bd2..e0b5ae96ac 100644 --- a/frontend/src/types/__generated__/moduleQueries.generated.ts +++ b/frontend/src/types/__generated__/moduleQueries.generated.ts @@ -14,7 +14,7 @@ export type GetModuleByIdQueryVariables = Types.Exact<{ }>; -export type GetModuleByIdQuery = { getModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, tags: Array | null, domains: Array | null, experienceLevel: Types.ExperienceLevelEnum, startedAt: any, endedAt: any, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }>, mentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> } }; +export type GetModuleByIdQuery = { getModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, tags: Array | null, domains: Array | null, experienceLevel: Types.ExperienceLevelEnum, startedAt: any, endedAt: any, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }>, mentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> } | null }; export type GetProgramAdminsAndModulesQueryVariables = Types.Exact<{ programKey: Types.Scalars['String']['input']; @@ -22,7 +22,7 @@ export type GetProgramAdminsAndModulesQueryVariables = Types.Exact<{ }>; -export type GetProgramAdminsAndModulesQuery = { getProgram: { __typename: 'ProgramNode', id: string, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null }, getModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, tags: Array | null, labels: Array | null, projectId: string | null, projectName: string | null, domains: Array | null, experienceLevel: Types.ExperienceLevelEnum, startedAt: any, endedAt: any, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }>, mentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> } }; +export type GetProgramAdminsAndModulesQuery = { getProgram: { __typename: 'ProgramNode', id: string, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } | null, getModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, tags: Array | null, labels: Array | null, projectId: string | null, projectName: string | null, domains: Array | null, experienceLevel: Types.ExperienceLevelEnum, startedAt: any, endedAt: any, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }>, mentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> } | null }; export type GetModuleIssuesQueryVariables = Types.Exact<{ programKey: Types.Scalars['String']['input']; @@ -33,7 +33,7 @@ export type GetModuleIssuesQueryVariables = Types.Exact<{ }>; -export type GetModuleIssuesQuery = { getModule: { __typename: 'ModuleNode', name: string, issuesCount: number, availableLabels: Array, issues: Array<{ __typename: 'IssueNode', id: string, number: number, title: string, state: string, isMerged: boolean, labels: Array, assignees: Array<{ __typename: 'UserNode', avatarUrl: string, login: string, name: string }> }> } }; +export type GetModuleIssuesQuery = { getModule: { __typename: 'ModuleNode', name: string, issuesCount: number, availableLabels: Array, issues: Array<{ __typename: 'IssueNode', id: string, number: number, title: string, state: string, isMerged: boolean, labels: Array, assignees: Array<{ __typename: 'UserNode', avatarUrl: string, login: string, name: string }> }> } | null }; export const GetModulesByProgramDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModulesByProgram"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgramModules"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"mentees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/src/types/__generated__/programsQueries.generated.ts b/frontend/src/types/__generated__/programsQueries.generated.ts index dc026675a2..1aaa267f08 100644 --- a/frontend/src/types/__generated__/programsQueries.generated.ts +++ b/frontend/src/types/__generated__/programsQueries.generated.ts @@ -15,21 +15,21 @@ export type GetProgramDetailsQueryVariables = Types.Exact<{ }>; -export type GetProgramDetailsQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, description: string, status: Types.ProgramStatusEnum, menteesLimit: number | null, experienceLevels: Array | null, startedAt: any, endedAt: any, domains: Array | null, tags: Array | null, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } }; +export type GetProgramDetailsQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, description: string, status: Types.ProgramStatusEnum, menteesLimit: number | null, experienceLevels: Array | null, startedAt: any, endedAt: any, domains: Array | null, tags: Array | null, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } | null }; export type GetProgramAndModulesQueryVariables = Types.Exact<{ programKey: Types.Scalars['String']['input']; }>; -export type GetProgramAndModulesQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, description: string, status: Types.ProgramStatusEnum, menteesLimit: number | null, experienceLevels: Array | null, startedAt: any, endedAt: any, domains: Array | null, tags: Array | null, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null }, getProgramModules: Array<{ __typename: 'ModuleNode', id: string, key: string, name: string, description: string, experienceLevel: Types.ExperienceLevelEnum, startedAt: any, endedAt: any, domains: Array | null, tags: Array | null, labels: Array | null, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }>, mentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> }> }; +export type GetProgramAndModulesQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, description: string, status: Types.ProgramStatusEnum, menteesLimit: number | null, experienceLevels: Array | null, startedAt: any, endedAt: any, domains: Array | null, tags: Array | null, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } | null, getProgramModules: Array<{ __typename: 'ModuleNode', id: string, key: string, name: string, description: string, experienceLevel: Types.ExperienceLevelEnum, startedAt: any, endedAt: any, domains: Array | null, tags: Array | null, labels: Array | null, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }>, mentees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> }> }; export type GetProgramAdminDetailsQueryVariables = Types.Exact<{ programKey: Types.Scalars['String']['input']; }>; -export type GetProgramAdminDetailsQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } }; +export type GetProgramAdminDetailsQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } | null }; export const GetMyProgramsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMyPrograms"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myPrograms"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalPages"}},{"kind":"Field","name":{"kind":"Name","value":"programs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"userRole"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/src/types/__generated__/projectsHealthDashboardQueries.generated.ts b/frontend/src/types/__generated__/projectsHealthDashboardQueries.generated.ts index 8ac5638ef0..6a96f432ef 100644 --- a/frontend/src/types/__generated__/projectsHealthDashboardQueries.generated.ts +++ b/frontend/src/types/__generated__/projectsHealthDashboardQueries.generated.ts @@ -4,7 +4,7 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ export type GetProjectHealthStatsQueryVariables = Types.Exact<{ [key: string]: never; }>; -export type GetProjectHealthStatsQuery = { projectHealthStats: { __typename: 'ProjectHealthStatsNode', averageScore: number, monthlyOverallScores: Array, monthlyOverallScoresMonths: Array, projectsCountHealthy: number, projectsCountNeedAttention: number, projectsCountUnhealthy: number, projectsPercentageHealthy: number, projectsPercentageNeedAttention: number, projectsPercentageUnhealthy: number, totalContributors: number, totalForks: number, totalStars: number } }; +export type GetProjectHealthStatsQuery = { projectHealthStats: { __typename: 'ProjectHealthStatsNode', averageScore: number | null, monthlyOverallScores: Array, monthlyOverallScoresMonths: Array, projectsCountHealthy: number, projectsCountNeedAttention: number, projectsCountUnhealthy: number, projectsPercentageHealthy: number, projectsPercentageNeedAttention: number, projectsPercentageUnhealthy: number, totalContributors: number, totalForks: number, totalStars: number } }; export type GetProjectHealthMetricsQueryVariables = Types.Exact<{ filters: Types.ProjectHealthMetricsFilter;