Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
79 changes: 72 additions & 7 deletions backend/apps/api/rest/v0/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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."),
)
Comment thread
Mr-Rahul-Paul marked this conversation as resolved.
15 changes: 15 additions & 0 deletions backend/apps/common/management/commands/dump_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ 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 = '' ;")],
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

dump_cmd = [
PG_DUMP,
"-h",
Expand Down
30 changes: 29 additions & 1 deletion backend/apps/owasp/admin/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,23 @@
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",
)
search_fields = (
"name",
"sort_name",
"description",
"contact_email",
)
list_filter = (
"status",
"sponsor_type",
"is_member",
"member_type",
Expand All @@ -35,6 +39,7 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"name",
"sort_name",
"description",
"contact_email",
)
},
),
Expand All @@ -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)
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.Status.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,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",
),
),
]
Original file line number Diff line number Diff line change
@@ -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,
)
]
Original file line number Diff line number Diff line change
@@ -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,
)
]
37 changes: 36 additions & 1 deletion backend/apps/owasp/models/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,35 @@ 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)
job_url = models.URLField(verbose_name="Job URL", blank=True)
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",
Expand All @@ -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}"
Expand Down
Loading