diff --git a/backend/apps/api/rest/v0/project.py b/backend/apps/api/rest/v0/project.py
index 7b26c3315a..e89bb19506 100644
--- a/backend/apps/api/rest/v0/project.py
+++ b/backend/apps/api/rest/v0/project.py
@@ -12,9 +12,11 @@
from apps.api.decorators.cache import cache_response
from apps.api.rest.v0.common import Leader, ValidationErrorSchema
+from apps.api.rest.v0.sponsor import Sponsor
from apps.api.rest.v0.structured_search import FieldConfig, apply_structured_search
from apps.owasp.models.enums.project import ProjectLevel, ProjectType
from apps.owasp.models.project import Project as ProjectModel
+from apps.owasp.models.sponsor import Sponsor as SponsorModel
PROJECT_SEARCH_FIELDS: dict[str, FieldConfig] = {
"name": {
@@ -145,3 +147,18 @@ def get_project(
return project
return Response({"message": "Project not found"}, status=HTTPStatus.NOT_FOUND)
+
+
+@router.get(
+ "/nest/sponsors",
+ description="Retrieve sponsors associated with project nest.",
+ 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 sponsors for a project."""
+ return SponsorModel.objects.filter(status=SponsorModel.StatusType.ACTIVE)
diff --git a/backend/apps/owasp/admin/sponsor.py b/backend/apps/owasp/admin/sponsor.py
index c124b7cd67..f7e5fe8b60 100644
--- a/backend/apps/owasp/admin/sponsor.py
+++ b/backend/apps/owasp/admin/sponsor.py
@@ -50,13 +50,7 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
),
(
"Status",
- {
- "fields": (
- "is_member",
- "member_type",
- "sponsor_type",
- )
- },
+ {"fields": ("is_member", "member_type", "sponsor_type", "status")},
),
)
diff --git a/backend/apps/owasp/api/internal/mutations/__init__.py b/backend/apps/owasp/api/internal/mutations/__init__.py
new file mode 100644
index 0000000000..8d8d00c7df
--- /dev/null
+++ b/backend/apps/owasp/api/internal/mutations/__init__.py
@@ -0,0 +1,3 @@
+"""Sponsor Mutations."""
+
+from .sponsor import SponsorMutation
diff --git a/backend/apps/owasp/api/internal/mutations/sponsor.py b/backend/apps/owasp/api/internal/mutations/sponsor.py
new file mode 100644
index 0000000000..ea49a86bad
--- /dev/null
+++ b/backend/apps/owasp/api/internal/mutations/sponsor.py
@@ -0,0 +1,40 @@
+"""OWASP sponsors GraphQL mutations."""
+
+import logging
+
+import strawberry
+from django.db import IntegrityError
+from django.utils import timezone
+
+from apps.common.utils import slugify
+from apps.owasp.api.internal.nodes.sponsor import CreateSponsorInput, SponsorNode
+from apps.owasp.models import Sponsor
+
+logger = logging.getLogger(__name__)
+
+SPONSOR_EXISTS_ERROR = "A sponsor with this name already exists."
+
+
+@strawberry.type
+class SponsorMutation:
+ """GraphQL mutations related to sponsor."""
+
+ @strawberry.mutation
+ def create_sponsor(self, input_data: CreateSponsorInput) -> SponsorNode:
+ """Create a new sponsor."""
+ try:
+ sponsor = Sponsor.objects.create(
+ key=slugify(input_data.name),
+ sort_name=input_data.name,
+ name=input_data.name,
+ contact_email=input_data.contact_email,
+ message=input_data.message,
+ url=input_data.url,
+ created_at=timezone.now(),
+ )
+ except IntegrityError as err:
+ raise ValueError(SPONSOR_EXISTS_ERROR) from err
+
+ logger.info("Sponsor created successfully")
+
+ return sponsor
diff --git a/backend/apps/owasp/api/internal/nodes/sponsor.py b/backend/apps/owasp/api/internal/nodes/sponsor.py
index c66cb269b3..5c9bfc824a 100644
--- a/backend/apps/owasp/api/internal/nodes/sponsor.py
+++ b/backend/apps/owasp/api/internal/nodes/sponsor.py
@@ -8,12 +8,17 @@
@strawberry_django.type(
Sponsor,
- fields=[
- "image_url",
- "name",
- "sponsor_type",
- "url",
- ],
+ fields=["image_url", "name", "sponsor_type", "url", "description"],
)
class SponsorNode(strawberry.relay.Node):
"""Sponsor node."""
+
+
+@strawberry.input
+class CreateSponsorInput:
+ """Input Node for creating a sponsor."""
+
+ message: str
+ name: str
+ url: str
+ contact_email: str
diff --git a/backend/apps/owasp/api/internal/queries/sponsor.py b/backend/apps/owasp/api/internal/queries/sponsor.py
index 80014e529f..2174fdcacb 100644
--- a/backend/apps/owasp/api/internal/queries/sponsor.py
+++ b/backend/apps/owasp/api/internal/queries/sponsor.py
@@ -25,3 +25,18 @@ def sponsors(self) -> list[SponsorNode]:
Sponsor.SponsorType.NOT_SPONSOR: 6,
}[x.sponsor_type],
)
+
+ @strawberry_django.field
+ def active_sponsors(self) -> list[SponsorNode]:
+ """Resolve active sponsors ordered by Sponsor level."""
+ return sorted(
+ Sponsor.objects.filter(status=Sponsor.StatusType.ACTIVE),
+ key=lambda x: {
+ Sponsor.SponsorType.DIAMOND: 1,
+ Sponsor.SponsorType.PLATINUM: 2,
+ Sponsor.SponsorType.GOLD: 3,
+ Sponsor.SponsorType.SILVER: 4,
+ Sponsor.SponsorType.SUPPORTER: 5,
+ Sponsor.SponsorType.NOT_SPONSOR: 6,
+ }[x.sponsor_type],
+ )
diff --git a/backend/apps/owasp/migrations/0073_sponsor_contact_email_sponsor_created_at.py b/backend/apps/owasp/migrations/0073_sponsor_contact_email_sponsor_created_at.py
new file mode 100644
index 0000000000..01a0b8bb09
--- /dev/null
+++ b/backend/apps/owasp/migrations/0073_sponsor_contact_email_sponsor_created_at.py
@@ -0,0 +1,22 @@
+# Generated by Django 6.0.3 on 2026-03-13 11:38
+
+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="contact_email",
+ field=models.EmailField(blank=True, max_length=254),
+ ),
+ migrations.AddField(
+ model_name="sponsor",
+ name="created_at",
+ field=models.DateTimeField(blank=True, null=True, verbose_name="Created At"),
+ ),
+ ]
diff --git a/backend/apps/owasp/migrations/0074_sponsor_chapter_sponsor_project_sponsor_status.py b/backend/apps/owasp/migrations/0074_sponsor_chapter_sponsor_project_sponsor_status.py
new file mode 100644
index 0000000000..d25891b512
--- /dev/null
+++ b/backend/apps/owasp/migrations/0074_sponsor_chapter_sponsor_project_sponsor_status.py
@@ -0,0 +1,36 @@
+# Generated by Django 6.0.3 on 2026-03-13 11:58
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0073_sponsor_contact_email_sponsor_created_at"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sponsor",
+ name="chapter",
+ field=models.ForeignKey(
+ null=True, on_delete=django.db.models.deletion.SET_NULL, to="owasp.chapter"
+ ),
+ ),
+ migrations.AddField(
+ model_name="sponsor",
+ name="project",
+ field=models.ForeignKey(
+ null=True, on_delete=django.db.models.deletion.SET_NULL, to="owasp.project"
+ ),
+ ),
+ migrations.AddField(
+ model_name="sponsor",
+ name="status",
+ field=models.CharField(
+ choices=[("Draft", "Draft"), ("Active", "Active"), ("Archived", "Archived")],
+ default="Draft",
+ max_length=20,
+ ),
+ ),
+ ]
diff --git a/backend/apps/owasp/migrations/0075_sponsor_message.py b/backend/apps/owasp/migrations/0075_sponsor_message.py
new file mode 100644
index 0000000000..2a6209a4a9
--- /dev/null
+++ b/backend/apps/owasp/migrations/0075_sponsor_message.py
@@ -0,0 +1,17 @@
+# Generated by Django 6.0.3 on 2026-03-21 14:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0074_sponsor_chapter_sponsor_project_sponsor_status"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sponsor",
+ name="message",
+ field=models.TextField(blank=True, verbose_name="Message"),
+ ),
+ ]
diff --git a/backend/apps/owasp/models/sponsor.py b/backend/apps/owasp/models/sponsor.py
index d7d0e38158..3359499cdd 100644
--- a/backend/apps/owasp/models/sponsor.py
+++ b/backend/apps/owasp/models/sponsor.py
@@ -37,11 +37,21 @@ class MemberType(models.TextChoices):
GOLD = "Gold"
SILVER = "Silver"
+ class StatusType(models.TextChoices):
+ """Status type choices."""
+
+ DRAFT = "Draft"
+ ACTIVE = "Active"
+ ARCHIVED = "Archived"
+
# Basic information
description = models.TextField(verbose_name="Description", blank=True)
key = models.CharField(verbose_name="Key", max_length=100, unique=True)
name = models.CharField(verbose_name="Name", max_length=255)
sort_name = models.CharField(verbose_name="Sort Name", max_length=255)
+ created_at = models.DateTimeField(verbose_name="Created At", null=True, blank=True)
+ contact_email = models.EmailField(blank=True)
+ message = models.TextField(verbose_name="Message", blank=True)
# URLs and images
url = models.URLField(verbose_name="Website URL", blank=True)
@@ -63,6 +73,12 @@ class MemberType(models.TextChoices):
choices=SponsorType.choices,
default=SponsorType.NOT_SPONSOR,
)
+ status = models.CharField(max_length=20, choices=StatusType.choices, default=StatusType.DRAFT)
+
+ # FKs
+ chapter = models.ForeignKey("owasp.Chapter", on_delete=models.SET_NULL, null=True, blank=True)
+
+ project = models.ForeignKey("owasp.Project", on_delete=models.SET_NULL, null=True, blank=True)
def __str__(self) -> str:
"""Sponsor human readable representation."""
diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py
index 4f7fd8691a..f0ddceba96 100644
--- a/backend/settings/graphql.py
+++ b/backend/settings/graphql.py
@@ -18,16 +18,12 @@
ProgramQuery,
)
from apps.nest.api.internal.mutations import NestMutations
+from apps.owasp.api.internal.mutations import SponsorMutation
from apps.owasp.api.internal.queries import OwaspQuery
@strawberry.type
-class Mutation(
- ApiMutations,
- ModuleMutation,
- NestMutations,
- ProgramMutation,
-):
+class Mutation(ApiMutations, ModuleMutation, NestMutations, ProgramMutation, SponsorMutation):
"""Schema mutations."""
diff --git a/docker-compose/local/compose.yaml b/docker-compose/local/compose.yaml
index e72fc74855..3aa1368566 100644
--- a/docker-compose/local/compose.yaml
+++ b/docker-compose/local/compose.yaml
@@ -31,7 +31,7 @@ services:
- 8000:8000
volumes:
- ../../backend:/home/owasp
- - backend-venv:/home/owasp/.venv
+ - backend-venv_4259:/home/owasp/.venv
cache:
command: >
@@ -50,7 +50,7 @@ services:
networks:
- nest-network
volumes:
- - cache-data:/data
+ - cache-data_4259:/data
db:
container_name: nest-db
@@ -67,7 +67,7 @@ services:
networks:
- nest-network
volumes:
- - db-data:/var/lib/postgresql/data
+ - db-data_4259:/var/lib/postgresql/data
docs:
container_name: nest-docs
@@ -89,7 +89,7 @@ services:
- ../../README.md:/home/owasp/README.md:ro
- ../../CODE_OF_CONDUCT.md:/home/owasp/CODE_OF_CONDUCT.md:ro
- ../../CONTRIBUTING.md:/home/owasp/CONTRIBUTING.md:ro
- - docs-venv:/home/owasp/.venv
+ - docs-venv_4259:/home/owasp/.venv
frontend:
container_name: nest-frontend
@@ -111,8 +111,8 @@ services:
- 3000:3000
volumes:
- ../../frontend:/home/owasp
- - frontend-next:/home/owasp/.next
- - frontend-node-modules:/home/owasp/node_modules
+ - frontend-next_4259:/home/owasp/.next
+ - frontend-node-modules_4259:/home/owasp/node_modules
worker:
container_name: nest-worker
@@ -141,15 +141,15 @@ services:
- nest-network
volumes:
- ../../backend:/home/owasp
- - backend-venv:/home/owasp/.venv
+ - backend-venv_4259:/home/owasp/.venv
networks:
nest-network:
volumes:
- backend-venv:
- cache-data:
- db-data:
- docs-venv:
- frontend-next:
- frontend-node-modules:
+ backend-venv_4259:
+ cache-data_4259:
+ db-data_4259:
+ docs-venv_4259:
+ frontend-next_4259:
+ frontend-node-modules_4259:
diff --git a/frontend/package.json b/frontend/package.json
index e3bbeadbd3..34d693dd29 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -62,7 +62,8 @@
"react-icons": "^5.6.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
- "tailwind-merge": "^3.5.0"
+ "tailwind-merge": "^3.5.0",
+ "validator": "^13.15.26"
},
"devDependencies": {
"@axe-core/react": "^4.11.1",
@@ -89,6 +90,7 @@
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@types/validator": "^13.15.10",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"eslint": "^10.0.3",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index c352494174..d9f9ab9bff 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -151,6 +151,9 @@ importers:
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
+ validator:
+ specifier: ^13.15.26
+ version: 13.15.26
devDependencies:
'@axe-core/react':
specifier: ^4.11.1
@@ -224,6 +227,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
+ '@types/validator':
+ specifier: ^13.15.10
+ version: 13.15.10
'@typescript-eslint/eslint-plugin':
specifier: ^8.58.0
version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
@@ -3528,6 +3534,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+ '@types/validator@13.15.10':
+ resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
+
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -7559,6 +7568,10 @@ packages:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
+ validator@13.15.26:
+ resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
+ engines: {node: '>= 0.10'}
+
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -12086,6 +12099,8 @@ snapshots:
'@types/trusted-types@2.0.7':
optional: true
+ '@types/validator@13.15.10': {}
+
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.5.0
@@ -16659,6 +16674,8 @@ snapshots:
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
+ validator@13.15.26: {}
+
vary@1.1.2: {}
w3c-xmlserializer@5.0.0:
diff --git a/frontend/src/app/sponsors/apply/page.tsx b/frontend/src/app/sponsors/apply/page.tsx
new file mode 100644
index 0000000000..f55a31bf54
--- /dev/null
+++ b/frontend/src/app/sponsors/apply/page.tsx
@@ -0,0 +1,70 @@
+'use client'
+import { useMutation } from '@apollo/client/react'
+import { addToast } from '@heroui/toast'
+import { useRouter } from 'next/navigation'
+import React, { useState } from 'react'
+
+import { CreateSponsorDocument } from 'types/__generated__/sponsorMutations.generated'
+import SponsorForm from 'components/SponsorForm'
+
+const SponsorApplicationPage = () => {
+ const router = useRouter()
+ const [createSponsor, { loading }] = useMutation(CreateSponsorDocument)
+
+ const [formData, setFormData] = useState({
+ name: '',
+ website: '',
+ contactEmail: '',
+ message: '',
+ })
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ try {
+ const input = {
+ name: formData.name,
+ url: formData.website,
+ contactEmail: formData.contactEmail,
+ message: formData.message,
+ }
+
+ await createSponsor({
+ variables: { input },
+ })
+
+ addToast({
+ description: 'Sponsorship application submitted successfully!',
+ title: 'Success',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'success',
+ variant: 'solid',
+ })
+
+ router.push('/sponsors')
+ } catch (err) {
+ addToast({
+ description:
+ err instanceof Error ? err.message : 'Unable to complete the requested operation.',
+ title: 'GraphQL Request Failed',
+ timeout: 3000,
+ shouldShowTimeoutProgress: true,
+ color: 'danger',
+ variant: 'solid',
+ })
+ }
+ }
+
+ return (
+
+ {sponsor.description} +
+ )} +No Sponsors Found
+ )} +