diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py
index 4641e7639c..cb2a0ae435 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"])
@@ -59,14 +60,30 @@ class SponsorFilter(FilterSchema):
sponsor_type: str | None = Field(
None,
- description="Filter by the type of sponsorship (e.g., Gold, Silver, Platinum).",
+ description=("Filter by the type of sponsorship (e.g., Gold, Silver, Platinum)."),
example="Silver",
)
+class SponsorApplyRequest(Schema):
+ """Request schema for sponsor application."""
+
+ organization_name: str = Field(..., min_length=1, description="Organization name")
+ website: str = Field("", description="Organization website URL")
+ contact_email: str = Field(..., min_length=1, description="Contact email address")
+ message: str = Field("", description="Sponsorship interest / message")
+
+
+class SponsorApplyResponse(Schema):
+ """Response schema for a successful sponsor application."""
+
+ key: str
+ message: str
+
+
@router.get(
"/",
- description="Retrieve a paginated list of OWASP sponsors.",
+ description="Retrieve a paginated list of active OWASP sponsors.",
operation_id="list_sponsors",
response=list[Sponsor],
summary="List sponsors",
@@ -80,13 +97,16 @@ def list_sponsors(
description="Ordering field",
),
) -> list[Sponsor]:
- """Get sponsors."""
- return filters.filter(SponsorModel.objects.order_by(ordering or "name"))
+ """Get active sponsors."""
+ qs = SponsorModel.objects.order_by(ordering or "name").filter(
+ status=SponsorModel.Status.ACTIVE
+ )
+ return filters.filter(qs)
@router.get(
"/{str:sponsor_id}",
- description="Retrieve a sponsor details.",
+ description="Retrieve sponsor details.",
operation_id="get_sponsor",
response={
HTTPStatus.BAD_REQUEST: ValidationErrorSchema,
@@ -100,8 +120,53 @@ def get_sponsor(
request: HttpRequest,
sponsor_id: str = Path(..., example="adobe"),
) -> SponsorDetail | SponsorError:
- """Get sponsor."""
- if sponsor := SponsorModel.objects.filter(key__iexact=sponsor_id).first():
+ """Get a single active sponsor."""
+ sponsor = SponsorModel.objects.filter(key__iexact=sponsor_id).first()
+ if sponsor and sponsor.status == SponsorModel.Status.ACTIVE:
return sponsor
return Response({"message": "Sponsor not found"}, status=HTTPStatus.NOT_FOUND)
+
+
+@router.post(
+ "/apply",
+ description=("Submit a sponsor application. Creates a draft record for admin review."),
+ operation_id="apply_sponsor",
+ response={
+ HTTPStatus.CREATED: SponsorApplyResponse,
+ HTTPStatus.BAD_REQUEST: SponsorError,
+ },
+ summary="Apply to become a sponsor",
+)
+def apply_sponsor(
+ request: HttpRequest,
+ payload: SponsorApplyRequest,
+) -> tuple[int, SponsorApplyResponse | SponsorError]:
+ """Create a draft sponsor application."""
+ organization_name = payload.organization_name.strip()
+ key = slugify(organization_name)
+
+ if not key:
+ return HTTPStatus.BAD_REQUEST, SponsorError(
+ message=("Invalid organization name. Please include at least one letter or number.")
+ )
+
+ if SponsorModel.objects.filter(key=key).exists():
+ return HTTPStatus.BAD_REQUEST, SponsorError(
+ message=(f"An application for '{organization_name}' already exists.")
+ )
+
+ SponsorModel.objects.create(
+ key=key,
+ name=organization_name,
+ sort_name=organization_name,
+ contact_email=payload.contact_email,
+ url=payload.website,
+ description=payload.message,
+ status=SponsorModel.Status.DRAFT,
+ )
+
+ return HTTPStatus.CREATED, SponsorApplyResponse(
+ key=key,
+ message=("Application received. The Nest team will review and follow up."),
+ )
diff --git a/backend/apps/common/management/commands/dump_data.py b/backend/apps/common/management/commands/dump_data.py
index e2dcc77668..23526b7cfb 100644
--- a/backend/apps/common/management/commands/dump_data.py
+++ b/backend/apps/common/management/commands/dump_data.py
@@ -106,6 +106,26 @@ def handle(self, *args, **options):
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"))
+ # Ensure dumps stay compatible with current constraints.
+ # Some environments may contain legacy rows with NULL status values.
+ self._execute_sql(
+ temp_db,
+ [
+ sql.SQL(
+ "UPDATE public.owasp_sponsors SET status = 'active' WHERE status IS NULL;"
+ )
+ ],
+ )
+ self._execute_sql(
+ temp_db,
+ [
+ sql.SQL(
+ "UPDATE public.owasp_sponsors SET contact_email = '' "
+ "WHERE contact_email IS NULL;"
+ )
+ ],
+ )
+
dump_cmd = [
PG_DUMP,
"-h",
diff --git a/backend/apps/owasp/admin/sponsor.py b/backend/apps/owasp/admin/sponsor.py
index c124b7cd67..19f5d28e09 100644
--- a/backend/apps/owasp/admin/sponsor.py
+++ b/backend/apps/owasp/admin/sponsor.py
@@ -10,10 +10,12 @@
class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"""Admin for Sponsor model."""
+ actions = ("activate_sponsors", "archive_sponsors")
list_display = (
"name",
- "sort_name",
+ "status",
"sponsor_type",
+ "contact_email",
"is_member",
"member_type",
)
@@ -21,8 +23,10 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"name",
"sort_name",
"description",
+ "contact_email",
)
list_filter = (
+ "status",
"sponsor_type",
"is_member",
"member_type",
@@ -35,6 +39,7 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"name",
"sort_name",
"description",
+ "contact_email",
)
},
),
@@ -52,13 +57,36 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"Status",
{
"fields": (
+ "status",
"is_member",
"member_type",
"sponsor_type",
)
},
),
+ (
+ "Entity Associations",
+ {
+ "fields": (
+ "chapter",
+ "project",
+ ),
+ "classes": ("collapse",),
+ },
+ ),
)
+ @admin.action(description="Activate selected sponsors")
+ def activate_sponsors(self, request, queryset) -> None:
+ """Set selected sponsors to active status."""
+ updated = queryset.update(status=Sponsor.Status.ACTIVE)
+ self.message_user(request, f"{updated} sponsor(s) marked as active.")
+
+ @admin.action(description="Archive selected sponsors")
+ def archive_sponsors(self, request, queryset) -> None:
+ """Set selected sponsors to archived status."""
+ updated = queryset.update(status=Sponsor.Status.ARCHIVED)
+ self.message_user(request, f"{updated} sponsor(s) archived.")
+
admin.site.register(Sponsor, SponsorAdmin)
diff --git a/backend/apps/owasp/api/internal/queries/sponsor.py b/backend/apps/owasp/api/internal/queries/sponsor.py
index 80014e529f..19374b90a8 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.Status.ACTIVE),
key=lambda x: {
Sponsor.SponsorType.DIAMOND: 1,
Sponsor.SponsorType.PLATINUM: 2,
diff --git a/backend/apps/owasp/migrations/0073_sponsor_status_contact_email_chapter_project.py b/backend/apps/owasp/migrations/0073_sponsor_status_contact_email_chapter_project.py
new file mode 100644
index 0000000000..76aa8e1f29
--- /dev/null
+++ b/backend/apps/owasp/migrations/0073_sponsor_status_contact_email_chapter_project.py
@@ -0,0 +1,62 @@
+# Generated by Django 5.2 on 2026-04-07 00:00
+
+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"),
+ ],
+ db_index=True,
+ default="active",
+ max_length=10,
+ verbose_name="Status",
+ ),
+ ),
+ migrations.AddField(
+ model_name="sponsor",
+ name="contact_email",
+ field=models.EmailField(blank=True, max_length=254, verbose_name="Contact Email"),
+ ),
+ migrations.AlterField(
+ model_name="sponsor",
+ name="sort_name",
+ field=models.CharField(blank=True, max_length=255, verbose_name="Sort Name"),
+ ),
+ 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/migrations/0074_backfill_sponsor_status_not_null.py b/backend/apps/owasp/migrations/0074_backfill_sponsor_status_not_null.py
new file mode 100644
index 0000000000..14600a71b7
--- /dev/null
+++ b/backend/apps/owasp/migrations/0074_backfill_sponsor_status_not_null.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2 on 2026-04-08 00:00
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0073_sponsor_status_contact_email_chapter_project"),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ sql="""
+ UPDATE public.owasp_sponsors
+ SET status = 'active'
+ WHERE status IS NULL;
+
+ ALTER TABLE public.owasp_sponsors
+ ALTER COLUMN status SET DEFAULT 'active';
+
+ ALTER TABLE public.owasp_sponsors
+ ALTER COLUMN status SET NOT NULL;
+ """,
+ reverse_sql=migrations.RunSQL.noop,
+ )
+ ]
diff --git a/backend/apps/owasp/migrations/0075_backfill_sponsor_contact_email_not_null.py b/backend/apps/owasp/migrations/0075_backfill_sponsor_contact_email_not_null.py
new file mode 100644
index 0000000000..e636687483
--- /dev/null
+++ b/backend/apps/owasp/migrations/0075_backfill_sponsor_contact_email_not_null.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2 on 2026-04-08 00:00
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0074_backfill_sponsor_status_not_null"),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ sql="""
+ UPDATE public.owasp_sponsors
+ SET contact_email = ''
+ WHERE contact_email IS NULL;
+
+ ALTER TABLE public.owasp_sponsors
+ ALTER COLUMN contact_email SET DEFAULT '';
+
+ ALTER TABLE public.owasp_sponsors
+ ALTER COLUMN contact_email SET NOT NULL;
+ """,
+ reverse_sql=migrations.RunSQL.noop,
+ )
+ ]
diff --git a/backend/apps/owasp/models/sponsor.py b/backend/apps/owasp/models/sponsor.py
index d7d0e38158..285ca72191 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 Status(models.TextChoices):
+ """Sponsor application status."""
+
+ DRAFT = "draft", "Draft"
+ ACTIVE = "active", "Active"
+ ARCHIVED = "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)
+ sort_name = models.CharField(verbose_name="Sort Name", max_length=255, blank=True)
+
+ # Contact
+ contact_email = models.EmailField(verbose_name="Contact Email", blank=True)
# URLs and images
url = models.URLField(verbose_name="Website URL", blank=True)
@@ -49,6 +59,13 @@ class MemberType(models.TextChoices):
image_url = models.CharField(verbose_name="Image Path", max_length=255, blank=True)
# Status fields
+ status = models.CharField(
+ verbose_name="Status",
+ max_length=10,
+ choices=Status.choices,
+ default=Status.ACTIVE,
+ db_index=True,
+ )
is_member = models.BooleanField(verbose_name="Is Corporate Sponsor", default=False)
member_type = models.CharField(
verbose_name="Member Type",
@@ -64,6 +81,24 @@ class MemberType(models.TextChoices):
default=SponsorType.NOT_SPONSOR,
)
+ # Optional entity associations
+ chapter = models.ForeignKey(
+ "owasp.Chapter",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="sponsors",
+ verbose_name="Chapter",
+ )
+ project = models.ForeignKey(
+ "owasp.Project",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=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..371fbaf68b 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,7 @@
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_sponsors
class TestSponsorSchema:
@@ -108,6 +108,7 @@ def test_get_sponsor_success(self, mock_sponsor_model):
"""Test getting a sponsor when found."""
mock_request = MagicMock()
mock_sponsor = MagicMock()
+ mock_sponsor.status = mock_sponsor_model.Status.ACTIVE
mock_sponsor_model.objects.filter.return_value.first.return_value = mock_sponsor
result = get_sponsor(mock_request, "adobe")
@@ -124,3 +125,46 @@ 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")
+ def test_apply_sponsor_rejects_name_that_creates_empty_key(self, mock_sponsor_model):
+ """Reject organization names that would create an empty key."""
+ mock_request = MagicMock()
+
+ payload = MagicMock()
+ payload.organization_name = " !!! "
+ payload.contact_email = "contact@example.com"
+ payload.website = ""
+ payload.message = ""
+
+ status, body = apply_sponsor(mock_request, payload)
+
+ assert status == HTTPStatus.BAD_REQUEST
+ assert body.message
+ mock_sponsor_model.objects.create.assert_not_called()
+
+ @patch("apps.api.rest.v0.sponsor.SponsorModel")
+ def test_apply_sponsor_strips_name_before_slugify_and_persist(self, mock_sponsor_model):
+ """Strip whitespace so we persist the cleaned organization name."""
+ mock_request = MagicMock()
+
+ payload = MagicMock()
+ payload.organization_name = " Acme, Inc. "
+ payload.contact_email = "contact@acme.com"
+ payload.website = "https://acme.com"
+ payload.message = "Hello"
+
+ mock_sponsor_model.objects.filter.return_value.exists.return_value = False
+
+ status, body = apply_sponsor(mock_request, payload)
+
+ assert status == HTTPStatus.CREATED
+ assert body.key
+ mock_sponsor_model.objects.create.assert_called_once()
+ _, kwargs = mock_sponsor_model.objects.create.call_args
+ assert kwargs["name"] == "Acme, Inc."
+ assert kwargs["sort_name"] == "Acme, Inc."
diff --git a/backend/tests/unit/apps/common/management/commands/dump_data_test.py b/backend/tests/unit/apps/common/management/commands/dump_data_test.py
index a4b6558a1d..ed7445b86a 100644
--- a/backend/tests/unit/apps/common/management/commands/dump_data_test.py
+++ b/backend/tests/unit/apps/common/management/commands/dump_data_test.py
@@ -98,6 +98,16 @@ def test_dump_data(self, mock_path, mock_connect, mock_popen, mock_run):
in executed_sql
)
+ assert (
+ str(
+ sql.SQL(
+ "UPDATE public.owasp_sponsors SET contact_email = '' "
+ "WHERE contact_email IS NULL;"
+ )
+ )
+ in executed_sql
+ )
+
assert mock_popen.call_count == 2
first_popen_call = mock_popen.call_args_list[0]
@@ -248,7 +258,7 @@ def test_dump_data_no_email_tables(self, mock_path, mock_connect, mock_popen, mo
# No UPDATE email queries should be in executed SQL.
executed_sql = [str(c.args[0]) for c in mock_cursor.execute.call_args_list]
- update_queries = [q for q in executed_sql if "UPDATE" in q and "email" in q]
+ update_queries = [q for q in executed_sql if "UPDATE" in q and " SET email " in q]
assert not update_queries
@override_settings(DATABASES=DATABASES)
diff --git a/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py b/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
index 2c60585bd2..6b4cb6ae79 100644
--- a/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
+++ b/backend/tests/unit/apps/owasp/api/internal/queries/sponsor_test.py
@@ -16,10 +16,13 @@ def test_sponsors_returns_sorted_by_type(self):
platinum = MagicMock()
platinum.sponsor_type = Sponsor.SponsorType.PLATINUM
- with patch.object(Sponsor.objects, "all", return_value=[silver, diamond, platinum]):
+ with patch.object(
+ Sponsor.objects, "filter", return_value=[silver, diamond, platinum]
+ ) as mock_filter:
query = SponsorQuery()
result = list(query.sponsors())
+ mock_filter.assert_called_once_with(status=Sponsor.Status.ACTIVE)
assert result[0] == diamond
assert result[1] == platinum
assert result[2] == silver
diff --git a/frontend/__tests__/unit/components/forms/shared/FormDateInput.test.tsx b/frontend/__tests__/unit/components/forms/shared/FormDateInput.test.tsx
index 2aa5c59fe4..2218b55f87 100644
--- a/frontend/__tests__/unit/components/forms/shared/FormDateInput.test.tsx
+++ b/frontend/__tests__/unit/components/forms/shared/FormDateInput.test.tsx
@@ -136,7 +136,8 @@ describe('FormDateInput Component', () => {
// The outer div in the component
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('w-full', 'min-w-0')
- expect(wrapper).toHaveStyle({ maxWidth: '100%', overflow: 'hidden' })
+ // Sizing is applied via Tailwind utility classes (not inline styles in JSDOM)
+ expect(wrapper).toHaveClass('max-w-full', 'overflow-hidden')
})
it('passes common class names to Input', () => {
diff --git a/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx b/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx
index e4257ad797..336c713427 100644
--- a/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx
+++ b/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx
@@ -4,6 +4,7 @@ import { FormTextarea } from 'components/forms/shared/FormTextarea'
describe('FormTextarea', () => {
const defaultProps = {
id: 'test-textarea',
+ name: 'test-textarea',
label: 'Test Label',
placeholder: 'Enter text',
value: '',
@@ -37,15 +38,15 @@ describe('FormTextarea', () => {
render(
+ No sponsors yet. Be the first to support OWASP Nest! +
+