Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
28 changes: 28 additions & 0 deletions backend/apps/api/rest/v0/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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": {
Expand Down Expand Up @@ -55,6 +56,7 @@ class ProjectDetail(ProjectBase):

description: str
leaders: list[Leader]
sponsors: list["ProjectSponsor"] = []

@staticmethod
def resolve_leaders(obj):
Expand All @@ -64,13 +66,39 @@ def resolve_leaders(obj):
for leader in obj.entity_leaders
]

@staticmethod
def resolve_sponsors(obj):
"""Resolve sponsors linked to this project."""
return [
ProjectSponsor(
key=sponsor.key,
name=sponsor.name,
sponsor_type=sponsor.sponsor_type,
image_url=sponsor.image_url,
url=sponsor.url,
description=sponsor.description,
)
for sponsor in obj.sponsors.filter(status=SponsorModel.Status.ACTIVE)
]


class ProjectError(Schema):
"""Project error schema."""

message: str


class ProjectSponsor(Schema):
"""Schema for project sponsors."""

image_url: str
key: str
name: str
sponsor_type: str
url: str
description: str = ""


class ProjectFilter(FilterSchema):
"""Filter for Project."""

Expand Down
91 changes: 91 additions & 0 deletions backend/apps/api/rest/v0/sponsor.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Sponsor API."""

import logging
from http import HTTPStatus
from typing import Literal

from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.http import HttpRequest
from ninja import Field, FilterSchema, Path, Query, Schema
from ninja.decorators import decorate_view
Expand All @@ -11,8 +14,11 @@

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

logger = logging.getLogger(__name__)

router = RouterPaginated(tags=["Sponsors"])


Expand Down Expand Up @@ -45,6 +51,23 @@ class SponsorError(Schema):
message: str


class SponsorApplicationRequest(Schema):
"""Schema for sponsor application request."""

name: str = Field(..., description="Organization name")
contact_email: str = Field(..., description="Contact email")
website: str | None = Field(None, description="Organization website (optional)")
sponsorship_interest: str = Field(..., description="Message about sponsorship interest")


class SponsorApplicationResponse(Schema):
"""Schema for sponsor application response."""

id: int
name: str
status: str


class SponsorFilter(FilterSchema):
"""Filter for Sponsor."""

Expand Down Expand Up @@ -105,3 +128,71 @@ def get_sponsor(
return sponsor

return Response({"message": "Sponsor not found"}, status=HTTPStatus.NOT_FOUND)


@router.post(
"/applications/",
description="Submit a sponsor application.",
operation_id="create_sponsor_application",
response={
HTTPStatus.BAD_REQUEST: SponsorError,
HTTPStatus.CREATED: SponsorApplicationResponse,
},
Comment thread
anurag2787 marked this conversation as resolved.
summary="Create sponsor application",
)
def create_sponsor_application(
request: HttpRequest,
payload: SponsorApplicationRequest,
) -> Response:
"""Create a sponsor application."""
try:
name = payload.name.strip()
contact_email = payload.contact_email.strip()
sponsorship_interest = payload.sponsorship_interest.strip()
website = (payload.website or "").strip()

if not name or not contact_email or not sponsorship_interest:
return Response(
{"message": "Name, contact email, and sponsorship interest are required"},
status=HTTPStatus.BAD_REQUEST,
)

key = slugify(name)
if not key:
return Response(
{"message": "Organization name is invalid"},
status=HTTPStatus.BAD_REQUEST,
)

if SponsorModel.objects.filter(key=key).exists():
return Response(
{"message": "A sponsor with this organization name already exists"},
status=HTTPStatus.BAD_REQUEST,
)

sponsor = SponsorModel(
name=name,
key=key,
contact_email=contact_email,
url=website,
description=sponsorship_interest,
status=SponsorModel.Status.DRAFT,
sort_name=name,
)
sponsor.full_clean()
sponsor.save()
Comment thread
anurag2787 marked this conversation as resolved.

return Response(
SponsorApplicationResponse(
id=sponsor.id,
name=sponsor.name,
status=sponsor.status,
),
status=HTTPStatus.CREATED,
)
except (ValueError, ValidationError, IntegrityError) as e:
logger.warning("Error creating sponsor application: %s", e)
return Response(
{"message": "Error creating sponsor application"},
status=HTTPStatus.BAD_REQUEST,
)
Comment thread
anurag2787 marked this conversation as resolved.
23 changes: 23 additions & 0 deletions backend/apps/owasp/admin/sponsor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,25 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"name",
"sort_name",
"sponsor_type",
"status",
"is_member",
"member_type",
"chapter",
"project",
)
search_fields = (
"name",
"sort_name",
"description",
"contact_email",
)
list_filter = (
"sponsor_type",
"status",
"is_member",
"member_type",
"chapter",
"project",
)
fieldsets = (
(
Expand All @@ -35,6 +42,7 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"name",
"sort_name",
"description",
"contact_email",
)
},
),
Expand All @@ -55,10 +63,25 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin):
"is_member",
"member_type",
"sponsor_type",
"status",
)
},
),
(
"Entity Associations",
{
"fields": (
"chapter",
"project",
),
"description": (
"Optional: Link this sponsor to a specific chapter or project. "
"Leave blank for global sponsors."
),
},
),
)
readonly_fields = ("nest_created_at", "nest_updated_at")


admin.site.register(Sponsor, SponsorAdmin)
7 changes: 7 additions & 0 deletions backend/apps/owasp/api/internal/mutations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""OWASP GraphQL mutations."""

from .sponsor import SponsorMutations


class OwaspMutations(SponsorMutations):
"""OWASP mutations."""
124 changes: 124 additions & 0 deletions backend/apps/owasp/api/internal/mutations/sponsor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""OWASP sponsors GraphQL mutations."""

import logging

import strawberry
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError

from apps.common.utils import slugify
from apps.owasp.api.internal.nodes.sponsor import SponsorNode
from apps.owasp.models.sponsor import Sponsor

logger = logging.getLogger(__name__)


@strawberry.type
class CreateSponsorApplicationResult:
"""Result of creating a sponsor application."""

ok: bool
sponsor: SponsorNode | None = None
code: str | None = None
message: str | None = None


@strawberry.type
class SponsorMutations:
"""Sponsor mutations."""

@strawberry.mutation
def create_sponsor_application(
self,
name: str,
contact_email: str,
sponsorship_interest: str,
website: str | None = None,
) -> CreateSponsorApplicationResult:
"""Create a sponsor application.

Args:
name: Organization name
contact_email: Contact email address
sponsorship_interest: Message about sponsorship interest
website: Organization website (optional)

Returns:
CreateSponsorApplicationResult with sponsor application status

"""
if not name or not name.strip():
return CreateSponsorApplicationResult(
ok=False,
code="INVALID_NAME",
message="Organization name is required",
)

if not contact_email or not contact_email.strip():
return CreateSponsorApplicationResult(
ok=False,
code="INVALID_EMAIL",
message="Contact email is required",
)

if not sponsorship_interest or not sponsorship_interest.strip():
return CreateSponsorApplicationResult(
ok=False,
code="INVALID_INTEREST",
message="Sponsorship interest message is required",
)

try:
name_clean = name.strip()
email_clean = contact_email.strip()
interest_clean = sponsorship_interest.strip()
url_clean = website.strip() if website else ""
key = slugify(name_clean)

# Validate before get_or_create to avoid saving invalid sponsor
temp_sponsor = Sponsor(
name=name_clean,
contact_email=email_clean,
url=url_clean,
description=interest_clean,
status=Sponsor.Status.DRAFT,
sort_name=name_clean,
key=key,
)
temp_sponsor.full_clean(validate_unique=False)

sponsor, created = Sponsor.objects.get_or_create(
Comment thread
anurag2787 marked this conversation as resolved.
key=key,
defaults={
"name": name_clean,
"contact_email": email_clean,
"url": url_clean,
"description": interest_clean,
"status": Sponsor.Status.DRAFT,
"sort_name": name_clean,
},
)

if not created:
return CreateSponsorApplicationResult(
ok=False,
code="DUPLICATE",
message="A sponsor with this organization name already exists",
)

logger.info("Sponsor application created: %s - %s", sponsor.id, sponsor.name)

return CreateSponsorApplicationResult(
ok=True,
sponsor=sponsor,
code="SUCCESS",
message="Sponsor application submitted successfully",
)

except (ValidationError, IntegrityError) as err:
logger.warning("Error creating sponsor application: %s", err)
return CreateSponsorApplicationResult(
ok=False,
code="ERROR",
message="Error submitting sponsor application",
)
7 changes: 7 additions & 0 deletions backend/apps/owasp/api/internal/nodes/chapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from apps.core.utils.index import deep_camelize
from apps.owasp.api.internal.nodes.common import GenericEntityNode
from apps.owasp.api.internal.nodes.sponsor import SponsorNode
from apps.owasp.models.chapter import Chapter
from apps.owasp.models.sponsor import Sponsor


@strawberry.type
Expand Down Expand Up @@ -61,3 +63,8 @@ def key(self, root: Chapter) -> str:
def suggested_location(self, root: Chapter) -> str | None:
"""Resolve suggested location."""
return root.idx_suggested_location

@strawberry_django.field(prefetch_related=["sponsors"])
def sponsors(self, root: Chapter) -> list[SponsorNode]:
"""Resolve active sponsors for this chapter."""
return root.sponsors.filter(status=Sponsor.Status.ACTIVE).order_by("name")
Loading