From 29c4ba989d77594f3d88d10920aa9b84b08f999d Mon Sep 17 00:00:00 2001 From: mrkeshav-05 Date: Sat, 11 Apr 2026 03:20:53 +0530 Subject: [PATCH 1/5] edit sponsor node --- backend/apps/owasp/api/internal/nodes/sponsor.py | 1 + backend/apps/owasp/api/internal/queries/sponsor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/apps/owasp/api/internal/nodes/sponsor.py b/backend/apps/owasp/api/internal/nodes/sponsor.py index c66cb269b3..e88186a6eb 100644 --- a/backend/apps/owasp/api/internal/nodes/sponsor.py +++ b/backend/apps/owasp/api/internal/nodes/sponsor.py @@ -9,6 +9,7 @@ @strawberry_django.type( Sponsor, fields=[ + "description", "image_url", "name", "sponsor_type", diff --git a/backend/apps/owasp/api/internal/queries/sponsor.py b/backend/apps/owasp/api/internal/queries/sponsor.py index 80014e529f..c1f4476918 100644 --- a/backend/apps/owasp/api/internal/queries/sponsor.py +++ b/backend/apps/owasp/api/internal/queries/sponsor.py @@ -15,7 +15,7 @@ class SponsorQuery: def sponsors(self) -> list[SponsorNode]: """Resolve sponsors.""" return sorted( - Sponsor.objects.all(), + Sponsor.objects.filter(status=Sponsor.SponsorStatus.ACTIVE), key=lambda x: { Sponsor.SponsorType.DIAMOND: 1, Sponsor.SponsorType.PLATINUM: 2, From 684ce403be83440b5549d2aff31fe43323e1a8e7 Mon Sep 17 00:00:00 2001 From: mrkeshav-05 Date: Sun, 12 Apr 2026 14:38:40 +0530 Subject: [PATCH 2/5] update code --- backend/apps/api/rest/v0/sponsor.py | 94 +++++- backend/apps/owasp/admin/sponsor.py | 22 ++ .../0073_sponsor_status_email_entity_fks.py | 61 ++++ backend/apps/owasp/models/sponsor.py | 32 ++ .../unit/apps/api/rest/v0/sponsor_test.py | 106 +++++- frontend/src/app/sponsors/apply/page.tsx | 306 ++++++++++++++++++ frontend/src/app/sponsors/layout.tsx | 9 + frontend/src/app/sponsors/page.tsx | 228 +++++++++++++ frontend/src/components/LogoCarousel.tsx | 7 +- frontend/src/server/queries/homeQueries.ts | 1 + frontend/src/server/queries/sponsorQueries.ts | 14 + frontend/src/types/__generated__/graphql.ts | 1 + .../__generated__/homeQueries.generated.ts | 4 +- .../__generated__/sponsorQueries.generated.ts | 10 + frontend/src/types/home.ts | 1 + frontend/src/utils/constants.ts | 4 +- frontend/src/utils/metadata.ts | 7 + 17 files changed, 891 insertions(+), 16 deletions(-) create mode 100644 backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py create mode 100644 frontend/src/app/sponsors/apply/page.tsx create mode 100644 frontend/src/app/sponsors/layout.tsx create mode 100644 frontend/src/app/sponsors/page.tsx create mode 100644 frontend/src/server/queries/sponsorQueries.ts create mode 100644 frontend/src/types/__generated__/sponsorQueries.generated.ts diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py index 4641e7639c..ed6a19f1a2 100644 --- a/backend/apps/api/rest/v0/sponsor.py +++ b/backend/apps/api/rest/v0/sponsor.py @@ -11,6 +11,7 @@ from apps.api.decorators.cache import cache_response from apps.api.rest.v0.common import ValidationErrorSchema +from apps.common.utils import slugify from apps.owasp.models.sponsor import Sponsor as SponsorModel router = RouterPaginated(tags=["Sponsors"]) @@ -19,6 +20,7 @@ class SponsorBase(Schema): """Base schema for Sponsor (used in list endpoints).""" + description: str image_url: str key: str name: str @@ -33,10 +35,10 @@ class Sponsor(SponsorBase): class SponsorDetail(SponsorBase): """Detail schema for Sponsor (used in single item endpoints).""" - description: str is_member: bool job_url: str member_type: str + status: str class SponsorError(Schema): @@ -45,6 +47,22 @@ class SponsorError(Schema): message: str +class SponsorApplication(Schema): + """Schema for sponsor application form submission.""" + + organization_name: str = Field(..., description="Name of the sponsoring organization") + website: str = Field("", description="Organization website URL") + contact_email: str = Field(..., description="Contact email address") + message: str = Field("", description="Sponsorship interest or message") + + +class SponsorApplicationResponse(Schema): + """Response schema for sponsor application.""" + + message: str + key: str + + class SponsorFilter(FilterSchema): """Filter for Sponsor.""" @@ -63,6 +81,11 @@ class SponsorFilter(FilterSchema): example="Silver", ) + status: str | None = Field( + None, + description="Filter by sponsor status (draft, active, archived). Defaults to active.", + ) + @router.get( "/", @@ -81,7 +104,74 @@ def list_sponsors( ), ) -> list[Sponsor]: """Get sponsors.""" - return filters.filter(SponsorModel.objects.order_by(ordering or "name")) + qs = SponsorModel.objects.order_by(ordering or "name") + if filters.status is None: + qs = qs.filter(status=SponsorModel.SponsorStatus.ACTIVE) + + return filters.filter(qs) + + +@router.get( + "/nest", + description="Retrieve active OWASP Nest sponsors for external integrations.", + operation_id="list_nest_sponsors", + response=list[Sponsor], + summary="List Nest sponsors", +) +@decorate_view(cache_response()) +def list_nest_sponsors( + request: HttpRequest, +) -> list[Sponsor]: + """Get active Nest sponsors for external integrations (GitHub Actions, dashboards, etc.).""" + return list( + SponsorModel.objects.filter(status=SponsorModel.SponsorStatus.ACTIVE).order_by( + "sponsor_type", "name" + ) + ) + + +@router.post( + "/apply", + description="Submit a sponsor application. Creates a new sponsor record with draft status.", + operation_id="apply_sponsor", + response={ + HTTPStatus.BAD_REQUEST: ValidationErrorSchema, + HTTPStatus.CREATED: SponsorApplicationResponse, + }, + summary="Apply to become a sponsor", +) +def apply_sponsor( + request: HttpRequest, + payload: SponsorApplication, +) -> Response: + """Submit a sponsor application.""" + key = slugify(payload.organization_name) + + if SponsorModel.objects.filter(key=key).exists(): + return Response( + {"message": "A sponsor application with this organization name already exists."}, + status=HTTPStatus.BAD_REQUEST, + ) + + sponsor = SponsorModel( + contact_email=payload.contact_email, + description=payload.message, + key=key, + name=payload.organization_name, + sort_name=payload.organization_name, + status=SponsorModel.SponsorStatus.DRAFT, + url=payload.website, + ) + sponsor.save() + + return Response( + { + "message": "Sponsor application submitted successfully. " + "It will be reviewed by the OWASP team.", + "key": key, + }, + status=HTTPStatus.CREATED, + ) @router.get( diff --git a/backend/apps/owasp/admin/sponsor.py b/backend/apps/owasp/admin/sponsor.py index c124b7cd67..cd164b66e3 100644 --- a/backend/apps/owasp/admin/sponsor.py +++ b/backend/apps/owasp/admin/sponsor.py @@ -14,15 +14,19 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin): "name", "sort_name", "sponsor_type", + "status", "is_member", "member_type", + "contact_email", ) search_fields = ( "name", "sort_name", "description", + "contact_email", ) list_filter = ( + "status", "sponsor_type", "is_member", "member_type", @@ -48,16 +52,34 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin): ) }, ), + ( + "Contact", + {"fields": ("contact_email",)}, + ), ( "Status", { "fields": ( + "status", "is_member", "member_type", "sponsor_type", ) }, ), + ( + "Entity Association", + { + "fields": ( + "chapter", + "project", + ), + "description": ( + "Optionally associate this sponsor with a specific chapter or project. " + "Leave blank for global/general sponsors." + ), + }, + ), ) diff --git a/backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py b/backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py new file mode 100644 index 0000000000..a27c31b9f8 --- /dev/null +++ b/backend/apps/owasp/migrations/0073_sponsor_status_email_entity_fks.py @@ -0,0 +1,61 @@ +# Generated manually for sponsor status, email, and entity FK fields. + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0072_project_project_name_gin_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="sponsor", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("active", "Active"), + ("archived", "Archived"), + ], + default="active", + max_length=20, + verbose_name="Status", + ), + ), + migrations.AddField( + model_name="sponsor", + name="contact_email", + field=models.EmailField( + blank=True, + default="", + max_length=254, + verbose_name="Contact Email", + ), + ), + migrations.AddField( + model_name="sponsor", + name="chapter", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sponsors", + to="owasp.chapter", + verbose_name="Chapter", + ), + ), + migrations.AddField( + model_name="sponsor", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sponsors", + to="owasp.project", + verbose_name="Project", + ), + ), + ] diff --git a/backend/apps/owasp/models/sponsor.py b/backend/apps/owasp/models/sponsor.py index d7d0e38158..3d40894935 100644 --- a/backend/apps/owasp/models/sponsor.py +++ b/backend/apps/owasp/models/sponsor.py @@ -20,6 +20,13 @@ class Meta: db_table = "owasp_sponsors" verbose_name_plural = "Sponsors" + class SponsorStatus(models.TextChoices): + """Sponsor status choices.""" + + DRAFT = "draft", "Draft" + ACTIVE = "active", "Active" + ARCHIVED = "archived", "Archived" + class SponsorType(models.TextChoices): """Sponsor type choices.""" @@ -47,8 +54,15 @@ class MemberType(models.TextChoices): url = models.URLField(verbose_name="Website URL", blank=True) job_url = models.URLField(verbose_name="Job URL", blank=True) image_url = models.CharField(verbose_name="Image Path", max_length=255, blank=True) + contact_email = models.EmailField(verbose_name="Contact Email", blank=True, default="") # Status fields + status = models.CharField( + verbose_name="Status", + max_length=20, + choices=SponsorStatus.choices, + default=SponsorStatus.ACTIVE, + ) is_member = models.BooleanField(verbose_name="Is Corporate Sponsor", default=False) member_type = models.CharField( verbose_name="Member Type", @@ -64,6 +78,24 @@ class MemberType(models.TextChoices): default=SponsorType.NOT_SPONSOR, ) + # Entity associations (optional) + chapter = models.ForeignKey( + "owasp.Chapter", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="sponsors", + verbose_name="Chapter", + ) + project = models.ForeignKey( + "owasp.Project", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="sponsors", + verbose_name="Project", + ) + def __str__(self) -> str: """Sponsor human readable representation.""" return f"{self.name}" diff --git a/backend/tests/unit/apps/api/rest/v0/sponsor_test.py b/backend/tests/unit/apps/api/rest/v0/sponsor_test.py index 5e89a5b30d..e579b9402d 100644 --- a/backend/tests/unit/apps/api/rest/v0/sponsor_test.py +++ b/backend/tests/unit/apps/api/rest/v0/sponsor_test.py @@ -3,7 +3,13 @@ import pytest -from apps.api.rest.v0.sponsor import SponsorDetail, get_sponsor, list_sponsors +from apps.api.rest.v0.sponsor import ( + SponsorDetail, + apply_sponsor, + get_sponsor, + list_nest_sponsors, + list_sponsors, +) class TestSponsorSchema: @@ -19,6 +25,7 @@ class TestSponsorSchema: "member_type": "PLATINUM", "name": "Gold Sponsor Inc.", "sponsor_type": "GOLD", + "status": "active", "url": "https://goldsponsor.com", }, { @@ -30,6 +37,7 @@ class TestSponsorSchema: "member_type": "SILVER", "name": "Silver Sponsor LLC", "sponsor_type": "SILVER", + "status": "active", "url": "https://silversponsor.com", }, ], @@ -46,6 +54,7 @@ def test_sponsor_schema_creation(self, sponsor_data): assert sponsor.member_type == sponsor_data["member_type"] assert sponsor.name == sponsor_data["name"] assert sponsor.sponsor_type == sponsor_data["sponsor_type"] + assert sponsor.status == sponsor_data["status"] assert sponsor.url == sponsor_data["url"] def test_sponsor_schema_with_minimal_data(self): @@ -59,6 +68,7 @@ def test_sponsor_schema_with_minimal_data(self): "member_type": "", "name": "Test Sponsor", "sponsor_type": "SILVER", + "status": "draft", "url": "", } sponsor = SponsorDetail(**minimal_data) @@ -66,6 +76,7 @@ def test_sponsor_schema_with_minimal_data(self): assert sponsor.job_url == "" assert sponsor.key == "test-sponsor" assert sponsor.name == "Test Sponsor" + assert sponsor.status == "draft" class TestListSponsors: @@ -76,30 +87,76 @@ def test_list_sponsors_no_ordering(self, mock_sponsor_model): """Test listing sponsors without ordering.""" mock_request = MagicMock() mock_filters = MagicMock() + mock_filters.status = None mock_queryset = MagicMock() + mock_filtered_queryset = MagicMock() mock_sponsor_model.objects.order_by.return_value = mock_queryset - mock_filters.filter.return_value = mock_queryset + mock_queryset.filter.return_value = mock_filtered_queryset + mock_filters.filter.return_value = mock_filtered_queryset result = list_sponsors(mock_request, mock_filters, ordering=None) mock_sponsor_model.objects.order_by.assert_called_with("name") - assert result == mock_queryset + mock_queryset.filter.assert_called_once_with( + status=mock_sponsor_model.SponsorStatus.ACTIVE + ) + assert result == mock_filtered_queryset @patch("apps.api.rest.v0.sponsor.SponsorModel") def test_list_sponsors_with_ordering(self, mock_sponsor_model): """Test listing sponsors with custom ordering.""" mock_request = MagicMock() mock_filters = MagicMock() + mock_filters.status = None mock_queryset = MagicMock() + mock_filtered_queryset = MagicMock() mock_sponsor_model.objects.order_by.return_value = mock_queryset - mock_filters.filter.return_value = mock_queryset + mock_queryset.filter.return_value = mock_filtered_queryset + mock_filters.filter.return_value = mock_filtered_queryset result = list_sponsors(mock_request, mock_filters, ordering="-name") mock_sponsor_model.objects.order_by.assert_called_with("-name") + assert result == mock_filtered_queryset + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + def test_list_sponsors_with_status_filter(self, mock_sponsor_model): + """Test listing sponsors with explicit status filter skips default filtering.""" + mock_request = MagicMock() + mock_filters = MagicMock() + mock_filters.status = "draft" + mock_queryset = MagicMock() + mock_sponsor_model.objects.order_by.return_value = mock_queryset + mock_filters.filter.return_value = mock_queryset + + result = list_sponsors(mock_request, mock_filters, ordering=None) + + mock_sponsor_model.objects.order_by.assert_called_with("name") + mock_queryset.filter.assert_not_called() assert result == mock_queryset +class TestListNestSponsors: + """Tests for list_nest_sponsors endpoint.""" + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + def test_list_nest_sponsors(self, mock_sponsor_model): + """Test listing Nest sponsors returns active sponsors ordered by type and name.""" + mock_request = MagicMock() + mock_queryset = MagicMock() + mock_sponsor_model.objects.filter.return_value.order_by.return_value = mock_queryset + mock_queryset.__iter__ = MagicMock(return_value=iter([])) + + list_nest_sponsors(mock_request) + + mock_sponsor_model.objects.filter.assert_called_once_with( + status=mock_sponsor_model.SponsorStatus.ACTIVE + ) + mock_sponsor_model.objects.filter.return_value.order_by.assert_called_once_with( + "sponsor_type", "name" + ) + + class TestGetSponsor: """Tests for get_sponsor endpoint.""" @@ -124,3 +181,44 @@ def test_get_sponsor_not_found(self, mock_sponsor_model): result = get_sponsor(mock_request, "nonexistent") assert result.status_code == HTTPStatus.NOT_FOUND + + +class TestApplySponsor: + """Tests for apply_sponsor endpoint.""" + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + @patch("apps.api.rest.v0.sponsor.slugify") + def test_apply_sponsor_success(self, mock_slugify, mock_sponsor_model): + """Test successful sponsor application.""" + mock_request = MagicMock() + mock_slugify.return_value = "test-org" + mock_sponsor_model.objects.filter.return_value.exists.return_value = False + mock_sponsor_instance = MagicMock() + mock_sponsor_model.return_value = mock_sponsor_instance + mock_sponsor_model.SponsorStatus.DRAFT = "draft" + + payload = MagicMock() + payload.organization_name = "Test Org" + payload.website = "https://test.org" + payload.contact_email = "sponsor@test.org" + payload.message = "We'd like to sponsor OWASP." + + result = apply_sponsor(mock_request, payload) + + assert result.status_code == HTTPStatus.CREATED + mock_sponsor_instance.save.assert_called_once() + + @patch("apps.api.rest.v0.sponsor.SponsorModel") + @patch("apps.api.rest.v0.sponsor.slugify") + def test_apply_sponsor_duplicate(self, mock_slugify, mock_sponsor_model): + """Test sponsor application with duplicate organization name.""" + mock_request = MagicMock() + mock_slugify.return_value = "existing-org" + mock_sponsor_model.objects.filter.return_value.exists.return_value = True + + payload = MagicMock() + payload.organization_name = "Existing Org" + + result = apply_sponsor(mock_request, payload) + + assert result.status_code == HTTPStatus.BAD_REQUEST diff --git a/frontend/src/app/sponsors/apply/page.tsx b/frontend/src/app/sponsors/apply/page.tsx new file mode 100644 index 0000000000..f10f82e234 --- /dev/null +++ b/frontend/src/app/sponsors/apply/page.tsx @@ -0,0 +1,306 @@ +'use client' + +import { addToast } from '@heroui/toast' +import Link from 'next/link' +import { type ChangeEvent, type FormEvent, useState } from 'react' +import { FaArrowLeft, FaHandshake, FaPaperPlane } from 'react-icons/fa6' +import { API_URL } from 'utils/env.client' +import AnchorTitle from 'components/AnchorTitle' +import SecondaryCard from 'components/SecondaryCard' + +interface FormData { + organizationName: string + website: string + contactEmail: string + message: string +} + +interface FormErrors { + organizationName?: string + contactEmail?: string +} + +const validateForm = (data: FormData): FormErrors => { + const errors: FormErrors = {} + if (!data.organizationName.trim()) { + errors.organizationName = 'Organization name is required.' + } + if (!data.contactEmail.trim()) { + errors.contactEmail = 'Contact email is required.' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.contactEmail)) { + errors.contactEmail = 'Please enter a valid email address.' + } + return errors +} + +export default function SponsorApplyPage() { + const [formData, setFormData] = useState({ + organizationName: '', + website: '', + contactEmail: '', + message: '', + }) + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSubmitted, setIsSubmitted] = useState(false) + + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + + if (errors[name as keyof FormErrors]) { + setErrors((prev) => ({ ...prev, [name]: undefined })) + } + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + + const validationErrors = validateForm(formData) + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors) + return + } + + setIsSubmitting(true) + + try { + const payload: Record = {} + payload['contact_email'] = formData.contactEmail + payload['message'] = formData.message + payload['organization_name'] = formData.organizationName + payload['website'] = formData.website + const response = await fetch(`${API_URL}api/v0/sponsors/apply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(payload), + }) + + if (response.ok) { + setIsSubmitted(true) + addToast({ + description: 'Your sponsor application has been submitted for review.', + title: 'Application Submitted', + timeout: 5000, + shouldShowTimeoutProgress: true, + color: 'success', + variant: 'solid', + }) + } else { + const data = await response.json() + addToast({ + description: data.message || 'Failed to submit application. Please try again.', + title: 'Submission Failed', + timeout: 5000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) + } + } catch { + addToast({ + description: 'An unexpected error occurred. Please try again later.', + title: 'Error', + timeout: 5000, + shouldShowTimeoutProgress: true, + color: 'danger', + variant: 'solid', + }) + } finally { + setIsSubmitting(false) + } + } + + if (isSubmitted) { + return ( +
+
+ +
+
+ +
+

+ Thank You for Your Interest! +

+

+ Your sponsor application has been submitted successfully. Our team will review it + and reach out to you at {formData.contactEmail}. +

+
+ + View Sponsors + + + Back to Home + +
+
+
+
+
+ ) + } + + return ( +
+
+ + + Back to Sponsors + + +
+

+ Become a Sponsor +

+

+ Support the OWASP Nest project and gain visibility within the global cybersecurity + community. +

+
+ + }> +