From 231f5c232d056b35c53ba7ec23d1c7f17e9ecc66 Mon Sep 17 00:00:00 2001 From: AbhayMishra Date: Tue, 28 Jan 2025 05:27:49 +0530 Subject: [PATCH] added model, command , algolia , sponsors --- backend/Makefile | 1 + backend/apps/owasp/admin.py | 46 +++++ backend/apps/owasp/api/search/sponsor.py | 29 ++++ backend/apps/owasp/index/__init__.py | 1 + backend/apps/owasp/index/sponsor.py | 54 ++++++ .../management/commands/load_sponsor_data.py | 29 ++++ backend/apps/owasp/models/mixins/sponsor.py | 74 ++++++++ backend/apps/owasp/models/sponsor.py | 164 ++++++++++++++++++ backend/apps/slack/commands/sponsors.py | 26 +-- backend/apps/slack/common/handlers/sponsor.py | 105 +++++++++++ 10 files changed, 517 insertions(+), 12 deletions(-) create mode 100644 backend/apps/owasp/api/search/sponsor.py create mode 100644 backend/apps/owasp/index/sponsor.py create mode 100644 backend/apps/owasp/management/commands/load_sponsor_data.py create mode 100644 backend/apps/owasp/models/mixins/sponsor.py create mode 100644 backend/apps/owasp/models/sponsor.py create mode 100644 backend/apps/slack/common/handlers/sponsor.py diff --git a/backend/Makefile b/backend/Makefile index 97b80d7d1..efaaf36ee 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -42,6 +42,7 @@ index-data: load-data: @echo "Loading Nest data" @CMD="poetry run python manage.py load_data" $(MAKE) exec-backend-command + @CMD="poetry run python manage.py load_sponsor_data" $(MAKE) exec-backend-command merge-migrations: @CMD="poetry run python manage.py makemigrations --merge" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index 9c9c00c0f..3c04b4d50 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -7,6 +7,7 @@ from apps.owasp.models.committee import Committee from apps.owasp.models.event import Event from apps.owasp.models.project import Project +from apps.owasp.models.sponsor import Sponsor class GenericEntityAdminMixin: @@ -100,8 +101,53 @@ def custom_field_name(self, obj): custom_field_name.short_description = "Name" +class SponsorAdmin(admin.ModelAdmin): + """Admin configuration for Sponsor model.""" + + list_display = ( + 'name', + 'sort_name', + 'is_active_sponsor', + 'sponsor_type', + 'is_member', + 'member_type', + ) + + search_fields = ( + 'name', + 'sort_name', + 'description', + ) + + list_filter = ( + 'sponsor_type', + 'is_member', + 'member_type', + ) + + fieldsets = ( + ('Basic Information', { + 'fields': ('name', 'sort_name', 'description') + }), + ('URLs and Images', { + 'fields': ('url', 'job_url', 'image') + }), + ('Status', { + 'fields': ('is_member', 'member_type', 'sponsor_type') + }), + ) + + readonly_fields = ('is_active_sponsor',) + + def is_active_sponsor(self, obj): + """Display if sponsor is active.""" + return obj.is_active_sponsor + is_active_sponsor.boolean = True + is_active_sponsor.short_description = "Active Sponsor" + admin.site.register(Chapter, ChapterAdmin) admin.site.register(Committee, CommetteeAdmin) admin.site.register(Event, EventAdmin) admin.site.register(Project, ProjectAdmin) +admin.site.register(Sponsor, SponsorAdmin) diff --git a/backend/apps/owasp/api/search/sponsor.py b/backend/apps/owasp/api/search/sponsor.py new file mode 100644 index 000000000..f29a4a252 --- /dev/null +++ b/backend/apps/owasp/api/search/sponsor.py @@ -0,0 +1,29 @@ +"""OWASP app sponsor search API.""" + +from algoliasearch_django import raw_search +from apps.owasp.models.sponsor import Sponsor + +def get_sponsors(query, attributes=None, limit=25, page=1): + params = { + "attributesToHighlight": [], + "attributesToRetrieve": attributes + or [ + "idx_name", + "idx_sort_name", + "idx_description", + "idx_url", + "idx_job_url", + "idx_image", + "idx_member_type", + "idx_sponsor_type", + "idx_member_level", + "idx_sponsor_level", + "idx_is_member", + "idx_is_active_sponsor" + ], + "hitsPerPage": limit, + "minProximity": 4, + "page": page - 1, + "typoTolerance": "min" + } + return raw_search(Sponsor, query, params) diff --git a/backend/apps/owasp/index/__init__.py b/backend/apps/owasp/index/__init__.py index f74fb2449..bc10b09de 100644 --- a/backend/apps/owasp/index/__init__.py +++ b/backend/apps/owasp/index/__init__.py @@ -3,3 +3,4 @@ from apps.owasp.index.chapter import ChapterIndex from apps.owasp.index.committee import CommitteeIndex from apps.owasp.index.project import ProjectIndex +from apps.owasp.index.sponsor import SponsorIndex \ No newline at end of file diff --git a/backend/apps/owasp/index/sponsor.py b/backend/apps/owasp/index/sponsor.py new file mode 100644 index 000000000..36825d57d --- /dev/null +++ b/backend/apps/owasp/index/sponsor.py @@ -0,0 +1,54 @@ +"""OWASP app sponsor index.""" + +from algoliasearch_django import AlgoliaIndex +from algoliasearch_django.decorators import register +from apps.common.index import IS_LOCAL_BUILD, LOCAL_INDEX_LIMIT +from apps.owasp.models.sponsor import Sponsor + +@register(Sponsor) +class SponsorIndex(AlgoliaIndex): + index_name = "sponsors" + fields = ( + "idx_name", + "idx_sort_name", + "idx_description", + "idx_url", + "idx_job_url", + "idx_image", + "idx_member_type", + "idx_sponsor_type", + "idx_member_level", + "idx_sponsor_level", + "idx_is_member", + "idx_is_active_sponsor", + ) + settings = { + "attributesForFaceting": [ + "filterOnly(idx_name)", + "idx_is_active_sponsor", + ], + "indexLanguages": ["en"], + "customRanking": [ + "asc(idx_sort_name)", + ], + "ranking": [ + "typo", + "words", + "filters", + "proximity", + "attribute", + "exact", + "custom", + ], + "searchableAttributes": [ + "unordered(idx_name)", + "unordered(idx_sort_name)", + "unordered(idx_member_level)", + "unordered(idx_sponsor_level)", + ], + } + should_index = "is_indexable" + + def get_queryset(self): + qs = Sponsor.objects.all() + return qs[:LOCAL_INDEX_LIMIT] if IS_LOCAL_BUILD else qs diff --git a/backend/apps/owasp/management/commands/load_sponsor_data.py b/backend/apps/owasp/management/commands/load_sponsor_data.py new file mode 100644 index 000000000..9739731a5 --- /dev/null +++ b/backend/apps/owasp/management/commands/load_sponsor_data.py @@ -0,0 +1,29 @@ +import yaml +from django.core.management.base import BaseCommand +from apps.owasp.models.sponsor import Sponsor +from apps.github.utils import get_repository_file_content, normalize_url + +class Command(BaseCommand): + help = "Import sponsors from OWASP GitHub repository" + + def handle(self, *args, **kwargs): + url = "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/corp_members.yml" + yaml_content = get_repository_file_content(url).replace("\t", " ") + data = yaml.safe_load(yaml_content) + + for entry in data: + sponsor = Sponsor( + name=entry.get("name", ""), + sort_name=entry.get("sortname", ""), + description=entry.get("description", ""), + url=normalize_url(entry.get("url", "")), + job_url=normalize_url(entry.get("job_url", "") or ""), + image=entry.get("image", ""), + is_member=entry.get("member", False), + member_type=entry.get("membertype", "4") or "4", + sponsor_type=entry.get("sponsor", "-1") or "-1", + ) + sponsor.save() + self.stdout.write(self.style.SUCCESS(f"Successfully imported sponsor: {sponsor.name}")) + + self.stdout.write(self.style.SUCCESS("Finished importing sponsors")) diff --git a/backend/apps/owasp/models/mixins/sponsor.py b/backend/apps/owasp/models/mixins/sponsor.py new file mode 100644 index 000000000..62c139145 --- /dev/null +++ b/backend/apps/owasp/models/mixins/sponsor.py @@ -0,0 +1,74 @@ +"""OWASP app sponsor mixins.""" + +from apps.owasp.models.mixins.common import GenericEntityMixin + +class SponsorIndexMixin(GenericEntityMixin): + @property + def idx_created_at(self): + """Get created timestamp for index.""" + return self.nest_created_at + + @property + def idx_updated_at(self): + """Get updated timestamp for index.""" + return self.nest_updated_at + + @property + def idx_name(self): + """Get name for index.""" + return self.name + + @property + def idx_sort_name(self): + """Get sort name for index.""" + return self.sort_name + + @property + def idx_description(self): + """Get description for index.""" + return self.description + + @property + def idx_url(self): + """Get URL for index.""" + return self.url + + @property + def idx_job_url(self): + """Get job URL for index.""" + return self.job_url + + @property + def idx_image(self): + """Get image path for index.""" + return self.image + + @property + def idx_member_type(self): + """Get member type for index.""" + return self.member_type + + @property + def idx_sponsor_type(self): + """Get sponsor type for index.""" + return self.sponsor_type + + @property + def idx_member_level(self): + """Get member level for index.""" + return self.member_level + + @property + def idx_sponsor_level(self): + """Get sponsor level for index.""" + return self.sponsor_level + + @property + def idx_is_member(self): + """Get member status for index.""" + return self.is_member + + @property + def idx_is_active_sponsor(self): + """Get active sponsor status for index.""" + return self.is_active_sponsor \ No newline at end of file diff --git a/backend/apps/owasp/models/sponsor.py b/backend/apps/owasp/models/sponsor.py new file mode 100644 index 000000000..76667b1e8 --- /dev/null +++ b/backend/apps/owasp/models/sponsor.py @@ -0,0 +1,164 @@ +"""OWASP app sponsor models.""" +from django.db import models +from apps.common.models import BulkSaveModel, TimestampedModel + + +class Sponsor( BulkSaveModel, TimestampedModel): + """Sponsor model.""" + + objects = models.Manager() + + class Meta: + db_table = "owasp_sponsors" + verbose_name_plural = "Sponsors" + + class SponsorType(models.TextChoices): + DIAMOND = "1", "Diamond" + PLATINUM = "2", "Platinum" + GOLD = "3", "Gold" + SILVER = "4", "Silver" + SUPPORTER = "5", "Supporter" + NOT_SPONSOR = "-1", "Not a Sponsor" + + class MemberType(models.TextChoices): + PLATINUM = "2", "Platinum" + GOLD = "3", "Gold" + SILVER = "4", "Silver" + + # Basic information + name = models.CharField(verbose_name="Name", max_length=255) + sort_name = models.CharField(verbose_name="Sort Name", max_length=255) + description = models.TextField(verbose_name="Description", blank=True) + + # URLs and images + url = models.URLField(verbose_name="Website URL", blank=True, null=True) + job_url = models.URLField(verbose_name="Job URL", blank=True, null=True) + image = models.CharField(verbose_name="Image Path", max_length=255, blank=True) + + # Status fields + is_member = models.BooleanField(verbose_name="Is Corporate Member", default=False) + member_type = models.CharField( + verbose_name="Member Type", + max_length=2, + choices=MemberType.choices, + default=MemberType.SILVER, + blank=True, + null=True + ) + sponsor_type = models.CharField( + verbose_name="Sponsor Type", + max_length=2, + choices=SponsorType.choices, + default=SponsorType.NOT_SPONSOR, + null=True + ) + + @property + def idx_created_at(self): + """Get created timestamp for index.""" + return self.nest_created_at + + @property + def idx_updated_at(self): + """Get updated timestamp for index.""" + return self.nest_updated_at + + @property + def idx_name(self): + """Get name for index.""" + return self.name + + @property + def idx_sort_name(self): + """Get sort name for index.""" + return self.sort_name + + @property + def idx_description(self): + """Get description for index.""" + return self.description + + @property + def idx_url(self): + """Get URL for index.""" + return self.url + + @property + def idx_job_url(self): + """Get job URL for index.""" + return self.job_url + + @property + def idx_image(self): + """Get image path for index.""" + return self.image + + @property + def idx_member_type(self): + """Get member type for index.""" + return self.member_type + + @property + def idx_sponsor_type(self): + """Get sponsor type for index.""" + return self.sponsor_type + + @property + def idx_member_level(self): + """Get member level for index.""" + return self.member_level + + @property + def idx_sponsor_level(self): + """Get sponsor level for index.""" + return self.sponsor_level + + @property + def idx_is_member(self): + """Get member status for index.""" + return self.is_member + + @property + def idx_is_active_sponsor(self): + """Get active sponsor status for index.""" + return self.is_active_sponsor + + def __str__(self): + """Sponsor human readable representation.""" + return f"{self.name}" + + @property + def is_active_sponsor(self): + """Check if the organization is an active sponsor.""" + return self.sponsor_type != self.SponsorType.NOT_SPONSOR + + @property + def sponsor_level(self): + """Get human-readable sponsor level.""" + return self.SponsorType(str(self.sponsor_type)).label if self.is_active_sponsor else None + + @property + def member_level(self): + """Get human-readable member level.""" + return self.MemberType(str(self.member_type)).label if self.member_type else None + @property + def is_indexable(self): + """Determine if the sponsor should be indexed in Algolia.""" + return True + + @staticmethod + def bulk_save(sponsors, fields=None): + """Bulk save sponsors.""" + BulkSaveModel.bulk_save(Sponsor, sponsors, fields=fields) + + @staticmethod + def update_data(sponsor_id, **kwargs): + """Update sponsor data.""" + try: + sponsor = Sponsor.objects.get(id=sponsor_id) + for key, value in kwargs.items(): + setattr(sponsor, key, value) + sponsor.save() + return sponsor + except Sponsor.DoesNotExist: + return None \ No newline at end of file diff --git a/backend/apps/slack/commands/sponsors.py b/backend/apps/slack/commands/sponsors.py index 8e204e331..0a065b5e6 100644 --- a/backend/apps/slack/commands/sponsors.py +++ b/backend/apps/slack/commands/sponsors.py @@ -1,30 +1,32 @@ """Slack bot sponsors command.""" from django.conf import settings - -from apps.common.constants import NL from apps.slack.apps import SlackConfig -from apps.slack.blocks import markdown +from apps.slack.common.handlers.sponsor import get_blocks +from apps.slack.common.presentation import EntityPresentation COMMAND = "/sponsors" - def sponsors_handler(ack, command, client): - """Slack /sponsors command handler.""" ack() - if not settings.SLACK_COMMANDS_ENABLED: return - blocks = [ - markdown( - f"Please visit page{NL}" + search_query = command["text"].strip() + blocks = get_blocks( + search_query=search_query, + limit=10, + presentation=EntityPresentation( + include_feedback=True, + include_metadata=True, + include_pagination=False, + include_timestamps=True, + name_truncation=80, + summary_truncation=300, ), - ] - + ) conversation = client.conversations_open(users=command["user_id"]) client.chat_postMessage(channel=conversation["channel"]["id"], blocks=blocks) - if SlackConfig.app: sponsors_handler = SlackConfig.app.command(COMMAND)(sponsors_handler) diff --git a/backend/apps/slack/common/handlers/sponsor.py b/backend/apps/slack/common/handlers/sponsor.py new file mode 100644 index 000000000..ea2373ca6 --- /dev/null +++ b/backend/apps/slack/common/handlers/sponsor.py @@ -0,0 +1,105 @@ +"""Handler for OWASP Sponsors Slack functionality.""" + +from __future__ import annotations + +from django.conf import settings +from django.utils.text import Truncator + +from apps.common.constants import NL +from apps.common.utils import get_absolute_url +from apps.slack.blocks import get_pagination_buttons, markdown +from apps.slack.common.constants import TRUNCATION_INDICATOR +from apps.slack.common.presentation import EntityPresentation +from apps.slack.constants import FEEDBACK_CHANNEL_MESSAGE +from apps.slack.utils import escape + + +def get_blocks( + page=1, search_query: str = "", limit: int = 10, presentation: EntityPresentation | None = None +): + """Get sponsors blocks.""" + from apps.owasp.api.search.sponsor import get_sponsors + from apps.owasp.models.sponsor import Sponsor + + presentation = presentation or EntityPresentation() + search_query_escaped = escape(search_query) + + attributes = [ + "idx_name", + "idx_description", + "idx_url", + "idx_sponsor_level", + "idx_is_active_sponsor", + ] + + offset = (page - 1) * limit + sponsors_data = get_sponsors(search_query, attributes=attributes, limit=limit, page=page) + sponsors = sponsors_data["hits"] + total_pages = sponsors_data["nbPages"] + + if not sponsors: + return [ + markdown( + f"*No sponsors found for `{search_query_escaped}`*{NL}" + if search_query + else f"*No sponsors found*{NL}" + ) + ] + + blocks = [ + markdown( + f"{NL}*OWASP sponsors that I found for* `{search_query_escaped}`:{NL}" + if search_query_escaped + else f"{NL}*OWASP sponsors:*{NL}" + ), + ] + + for idx, sponsor in enumerate(sponsors): + name = Truncator(escape(sponsor["idx_name"])).chars( + presentation.name_truncation, truncate=TRUNCATION_INDICATOR + ) + description = Truncator(sponsor["idx_description"]).chars( + presentation.summary_truncation, truncate=TRUNCATION_INDICATOR + ) + + sponsor_level = sponsor.get("idx_sponsor_level", "") + sponsor_level_text = ( + f"_Level: {sponsor_level}_{NL}" + if sponsor_level and presentation.include_metadata + else "" + ) + + is_active = sponsor.get("idx_is_active_sponsor", False) + status_text = "(Active)" if is_active else "(Inactive)" + + blocks.append( + markdown( + f"{offset + idx + 1}. <{sponsor['idx_url']}|*{name}*> {status_text}{NL}" + f"{sponsor_level_text}" + f"{escape(description)}{NL}" + ) + ) + + if presentation.include_feedback: + blocks.append( + markdown( + f"⚠️ *Extended search over OWASP sponsors " + f"is available at <{get_absolute_url('sponsors')}?q={search_query}|{settings.SITE_NAME}>*{NL}" + f"{FEEDBACK_CHANNEL_MESSAGE}" + ) + ) + if presentation.include_pagination and ( + pagination_block := get_pagination_buttons( + "sponsors", + page, + total_pages - 1, + ) + ): + blocks.append( + { + "type": "actions", + "elements": pagination_block, + } + ) + + return blocks