Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 111 additions & 2 deletions backend/apps/api/rest/v0/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from http import HTTPStatus
from typing import Literal

from django.db import IntegrityError, transaction
from django.db.models import Case, IntegerField, Value, When
from django.http import HttpRequest
from ninja import Field, FilterSchema, Path, Query, Schema
from ninja.decorators import decorate_view
Expand All @@ -11,6 +13,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"])
Expand All @@ -19,6 +22,7 @@
class SponsorBase(Schema):
"""Base schema for Sponsor (used in list endpoints)."""

description: str
image_url: str
key: str
name: str
Expand All @@ -33,10 +37,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):
Expand All @@ -45,6 +49,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."""

Expand All @@ -63,6 +83,11 @@ class SponsorFilter(FilterSchema):
example="Silver",
)

status: str | None = Field(
None,
description="Filter by sponsor status (draft, active, archived). Defaults to active.",
)


@router.get(
"/",
Expand All @@ -81,7 +106,91 @@ 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",
)
Comment on lines +116 to +122
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue/requirements specify a REST endpoint like /api/v0/projects/nest/sponsors, but this PR introduces /api/v0/sponsors/nest. If external integrations are expected to follow the documented project-scoped route, consider adding the endpoint under the Projects router (or adding an alias/redirect) to avoid breaking the intended API contract.

Copilot uses AI. Check for mistakes.
@decorate_view(cache_response())
def list_nest_sponsors(
request: HttpRequest,
) -> list[Sponsor]:
"""Get active Nest sponsors for external integrations (GitHub Actions, dashboards, etc.)."""
tier_order = Case(
When(sponsor_type=SponsorModel.SponsorType.DIAMOND, then=Value(1)),
When(sponsor_type=SponsorModel.SponsorType.PLATINUM, then=Value(2)),
When(sponsor_type=SponsorModel.SponsorType.GOLD, then=Value(3)),
When(sponsor_type=SponsorModel.SponsorType.SILVER, then=Value(4)),
When(sponsor_type=SponsorModel.SponsorType.SUPPORTER, then=Value(5)),
default=Value(6),
output_field=IntegerField(),
)
return list(
SponsorModel.objects.filter(status=SponsorModel.SponsorStatus.ACTIVE)
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_nest_sponsors currently filters only by status=ACTIVE and will include rows where sponsor_type is NOT_SPONSOR (a defined non-sponsor value). For an endpoint explicitly named "Nest sponsors", it should likely exclude SponsorType.NOT_SPONSOR (and potentially use the new project/chapter associations) so consumers don’t get non-sponsor records.

Suggested change
SponsorModel.objects.filter(status=SponsorModel.SponsorStatus.ACTIVE)
SponsorModel.objects.filter(status=SponsorModel.SponsorStatus.ACTIVE)
.exclude(sponsor_type=SponsorModel.SponsorType.NOT_SPONSOR)

Copilot uses AI. Check for mistakes.
.annotate(tier_order=tier_order)
.order_by("tier_order", "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."""
Comment on lines +144 to +158
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /api/v0 Ninja API is configured with auth=ApiKey() (see backend/apps/api/rest/v0/__init__.py:41-48), so this POST endpoint will return 401 in production unless you explicitly override auth for this operation/router. Since the frontend sponsor application form calls this endpoint without an API key, consider setting auth=None for this route (and adding appropriate anti-abuse controls like throttling/CAPTCHA) or proxying the request through a server-side endpoint that can attach credentials safely.

Copilot uses AI. Check for mistakes.
key = slugify(payload.organization_name)

if not key:
return Response(
{"message": "Organization name must produce a valid key."},
status=HTTPStatus.BAD_REQUEST,
)

duplicate_response = Response(
{"message": "A sponsor application with this organization name already exists."},
status=HTTPStatus.BAD_REQUEST,
)

try:
with transaction.atomic():
SponsorModel.objects.create(
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,
)
except IntegrityError:
return duplicate_response

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return Response(
{
"message": "Sponsor application submitted successfully. "
"It will be reviewed by the OWASP team.",
"key": key,
},
status=HTTPStatus.CREATED,
)


@router.get(
Expand Down
22 changes: 22 additions & 0 deletions backend/apps/owasp/admin/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."
),
},
),
)


Expand Down
1 change: 1 addition & 0 deletions backend/apps/owasp/api/internal/nodes/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@strawberry_django.type(
Sponsor,
fields=[
"description",
"image_url",
"name",
"sponsor_type",
Expand Down
2 changes: 1 addition & 1 deletion backend/apps/owasp/api/internal/queries/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
32 changes: 32 additions & 0 deletions backend/apps/owasp/models/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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",
Expand All @@ -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}"
Expand Down
Loading
Loading