diff --git a/backend/Makefile b/backend/Makefile index 4eb7cddf2..9eda82fb1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -98,6 +98,10 @@ owasp-scrape-projects: @echo "Scraping OWASP site projects data" @CMD="python manage.py owasp_scrape_projects" $(MAKE) exec-backend-command +owasp-update-events: + @echo "Getting OWASP events data" + @CMD="python manage.py owasp_update_events" $(MAKE) exec-backend-command + purge-data: @CMD="python manage.py purge_data" $(MAKE) exec-backend-command @@ -131,4 +135,5 @@ update-data: \ owasp-scrape-committees \ owasp-scrape-projects \ github-update-project-related-repositories \ - owasp-aggregate-projects + owasp-aggregate-projects \ + owasp-update-events diff --git a/backend/apps/common/utils.py b/backend/apps/common/utils.py index c80ed2bda..4523ce6e2 100644 --- a/backend/apps/common/utils.py +++ b/backend/apps/common/utils.py @@ -1,10 +1,12 @@ """Common app utils.""" +import re from datetime import datetime, timezone from django.conf import settings from django.template.defaultfilters import pluralize from django.utils.text import Truncator +from django.utils.text import slugify as django_slugify from humanize import intword, naturaltime @@ -35,6 +37,7 @@ def natural_date(value): value = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc) elif isinstance(value, int): value = datetime.fromtimestamp(value, tz=timezone.utc) + return naturaltime(value) @@ -44,6 +47,11 @@ def natural_number(value, unit=None): return f"{number} {unit}{pluralize(value)}" if unit else number +def slugify(text): + """Return slug for text.""" + return re.sub(r"-{2,}", "-", django_slugify(text)) + + def truncate(text, limit, truncate="..."): """Truncate text to the given limit.""" return Truncator(text).chars(limit, truncate=truncate) diff --git a/backend/apps/github/management/commands/github_update_owasp_organization.py b/backend/apps/github/management/commands/github_update_owasp_organization.py index d407ac492..e51969ba9 100644 --- a/backend/apps/github/management/commands/github_update_owasp_organization.py +++ b/backend/apps/github/management/commands/github_update_owasp_organization.py @@ -13,7 +13,6 @@ from apps.owasp.constants import OWASP_ORGANIZATION_NAME from apps.owasp.models.chapter import Chapter from apps.owasp.models.committee import Committee -from apps.owasp.models.event import Event from apps.owasp.models.project import Project logger = logging.getLogger(__name__) @@ -48,7 +47,6 @@ def handle(self, *_args, **options): chapters = [] committees = [] - events = [] projects = [] offset = options["offset"] @@ -84,17 +82,12 @@ def handle(self, *_args, **options): elif entity_key.startswith("www-project-"): projects.append(Project.update_data(gh_repository, repository, save=False)) - # OWASP events. - elif entity_key.startswith("www-event-"): - events.append(Event.update_data(gh_repository, repository, save=False)) - # OWASP committees. elif entity_key.startswith("www-committee-"): committees.append(Committee.update_data(gh_repository, repository, save=False)) Chapter.bulk_save(chapters) Committee.bulk_save(committees) - Event.bulk_save(events) Project.bulk_save(projects) # Check repository counts. diff --git a/backend/apps/github/utils.py b/backend/apps/github/utils.py index a98c38fc9..23502ac8d 100644 --- a/backend/apps/github/utils.py +++ b/backend/apps/github/utils.py @@ -46,7 +46,7 @@ def get_repository_path(url): def normalize_url(url, check_path=False): - """Normalize GitHub URL.""" + """Normalize URL.""" parsed_url = urlparse(url) if not parsed_url.netloc or (check_path and not parsed_url.path): return None diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index 51db63fcd..d09a25041 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -65,7 +65,6 @@ class CommitteeAdmin(admin.ModelAdmin): class EventAdmin(admin.ModelAdmin): - autocomplete_fields = ("owasp_repository",) list_display = ("name",) search_fields = ("name",) diff --git a/backend/apps/owasp/api/event.py b/backend/apps/owasp/api/event.py index 2420d1d90..8683c0c19 100644 --- a/backend/apps/owasp/api/event.py +++ b/backend/apps/owasp/api/event.py @@ -15,8 +15,6 @@ class Meta: "name", "description", "url", - "created_at", - "updated_at", ) diff --git a/backend/apps/owasp/graphql/nodes/event.py b/backend/apps/owasp/graphql/nodes/event.py new file mode 100644 index 000000000..e20023521 --- /dev/null +++ b/backend/apps/owasp/graphql/nodes/event.py @@ -0,0 +1,20 @@ +"""OWASP event GraphQL node.""" + +from apps.common.graphql.nodes import BaseNode +from apps.owasp.models.event import Event + + +class EventNode(BaseNode): + """Event node.""" + + class Meta: + model = Event + fields = ( + "category", + "end_date", + "description", + "key", + "name", + "start_date", + "url", + ) diff --git a/backend/apps/owasp/graphql/queries/__init__.py b/backend/apps/owasp/graphql/queries/__init__.py index 8515e957d..b921a9d79 100644 --- a/backend/apps/owasp/graphql/queries/__init__.py +++ b/backend/apps/owasp/graphql/queries/__init__.py @@ -2,9 +2,10 @@ from .chapter import ChapterQuery from .committee import CommitteeQuery +from .event import EventQuery from .project import ProjectQuery from .stats import StatsQuery -class OwaspQuery(ChapterQuery, CommitteeQuery, ProjectQuery, StatsQuery): +class OwaspQuery(ChapterQuery, CommitteeQuery, EventQuery, ProjectQuery, StatsQuery): """OWASP queries.""" diff --git a/backend/apps/owasp/graphql/queries/event.py b/backend/apps/owasp/graphql/queries/event.py new file mode 100644 index 000000000..f9f3edc48 --- /dev/null +++ b/backend/apps/owasp/graphql/queries/event.py @@ -0,0 +1,17 @@ +"""OWASP event GraphQL queries.""" + +import graphene + +from apps.common.graphql.queries import BaseQuery +from apps.owasp.graphql.nodes.event import EventNode +from apps.owasp.models.event import Event + + +class EventQuery(BaseQuery): + """Event queries.""" + + upcoming_events = graphene.List(EventNode) + + def resolve_upcoming_events(root, info): + """Resolve all events.""" + return Event.upcoming_events diff --git a/backend/apps/owasp/management/commands/owasp_update_events.py b/backend/apps/owasp/management/commands/owasp_update_events.py new file mode 100644 index 000000000..8ea14dc08 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_update_events.py @@ -0,0 +1,26 @@ +"""A command to update OWASP events.""" + +import yaml +from django.core.management.base import BaseCommand + +from apps.github.utils import get_repository_file_content +from apps.owasp.models.event import Event + + +class Command(BaseCommand): + help = "Import events from the provided YAML file" + + def handle(self, *args, **kwargs): + data = yaml.safe_load( + get_repository_file_content( + "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/events.yml" + ) + ) + + Event.bulk_save( + [ + Event.update_data(category["category"], event_data) + for category in data + for event_data in category["events"] + ] + ) diff --git a/backend/apps/owasp/migrations/0016_remove_event_created_at_and_more.py b/backend/apps/owasp/migrations/0016_remove_event_created_at_and_more.py new file mode 100644 index 000000000..9bf0bcbf1 --- /dev/null +++ b/backend/apps/owasp/migrations/0016_remove_event_created_at_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 5.1.5 on 2025-02-28 07:53 + +import datetime + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0015_snapshot"), + ] + + operations = [ + migrations.RemoveField( + model_name="event", + name="created_at", + ), + migrations.RemoveField( + model_name="event", + name="has_active_repositories", + ), + migrations.RemoveField( + model_name="event", + name="is_active", + ), + migrations.RemoveField( + model_name="event", + name="level", + ), + migrations.RemoveField( + model_name="event", + name="owasp_repository", + ), + migrations.RemoveField( + model_name="event", + name="summary", + ), + migrations.RemoveField( + model_name="event", + name="tags", + ), + migrations.RemoveField( + model_name="event", + name="topics", + ), + migrations.RemoveField( + model_name="event", + name="updated_at", + ), + migrations.AddField( + model_name="event", + name="category", + field=models.CharField( + choices=[ + ("global", "Global"), + ("appsec_days", "AppSec Days"), + ("partner", "Partner"), + ("other", "Other"), + ], + default="other", + max_length=20, + verbose_name="Category", + ), + ), + migrations.AddField( + model_name="event", + name="end_date", + field=models.DateField(blank=True, null=True, verbose_name="End Date"), + ), + migrations.AddField( + model_name="event", + name="start_date", + field=models.DateField( + default=datetime.datetime( + 2025, 2, 28, 7, 53, 17, 155842, tzinfo=datetime.timezone.utc + ), + verbose_name="Start Date", + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="event", + name="description", + field=models.TextField(blank=True, default="", verbose_name="Description"), + ), + ] diff --git a/backend/apps/owasp/migrations/0018_merge_20250302_1945.py b/backend/apps/owasp/migrations/0018_merge_20250302_1945.py new file mode 100644 index 000000000..69b3496e8 --- /dev/null +++ b/backend/apps/owasp/migrations/0018_merge_20250302_1945.py @@ -0,0 +1,12 @@ +# Generated by Django 5.1.6 on 2025-03-02 19:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0016_remove_event_created_at_and_more"), + ("owasp", "0017_projecthealthrequirements_and_more"), + ] + + operations = [] diff --git a/backend/apps/owasp/models/event.py b/backend/apps/owasp/models/event.py index 16ebbb250..81e9e4377 100644 --- a/backend/apps/owasp/models/event.py +++ b/backend/apps/owasp/models/event.py @@ -1,58 +1,132 @@ """OWASP app event model.""" +from dateutil import parser from django.db import models +from django.utils import timezone from apps.common.models import BulkSaveModel, TimestampedModel -from apps.owasp.models.common import RepositoryBasedEntityModel +from apps.common.utils import slugify +from apps.github.utils import normalize_url -class Event(BulkSaveModel, RepositoryBasedEntityModel, TimestampedModel): +class Event(BulkSaveModel, TimestampedModel): """Event model.""" class Meta: db_table = "owasp_events" verbose_name_plural = "Events" - level = models.CharField(verbose_name="Level", max_length=5, default="", blank=True) - url = models.URLField(verbose_name="URL", default="", blank=True) + class Category(models.TextChoices): + """Event category.""" + + APPSEC_DAYS = "appsec_days", "AppSec Days" + GLOBAL = "global", "Global" + OTHER = "other", "Other" + PARTNER = "partner", "Partner" - owasp_repository = models.ForeignKey( - "github.Repository", on_delete=models.SET_NULL, blank=True, null=True + category = models.CharField( + verbose_name="Category", + max_length=20, + choices=Category.choices, + default=Category.OTHER, ) + end_date = models.DateField(verbose_name="End Date", null=True, blank=True) + key = models.CharField(verbose_name="Key", max_length=100, unique=True) + name = models.CharField(verbose_name="Name", max_length=100) + description = models.TextField(verbose_name="Description", default="", blank=True) + start_date = models.DateField(verbose_name="Start Date") + url = models.URLField(verbose_name="URL", default="", blank=True) + def __str__(self): """Event human readable representation.""" return f"{self.name or self.key}" - def from_github(self, repository): - """Update instance based on GitHub repository data.""" - field_mapping = { - "description": "pitch", - "level": "level", - "name": "title", - "tags": "tags", - } - RepositoryBasedEntityModel.from_github(self, field_mapping, repository) - - # FKs. - self.owasp_repository = repository + @staticmethod + def upcoming_events(): + """Get upcoming events.""" + return Event.objects.filter(start_date__gte=timezone.now().date()).order_by("start_date") @staticmethod def bulk_save(events, fields=None): """Bulk save events.""" BulkSaveModel.bulk_save(Event, events, fields=fields) + # TODO(arkid15r): refactor this when there is a chance. + @staticmethod + def parse_dates(dates, start_date): + """Parse event dates.""" + if not dates: + return None + + # Handle single-day events (e.g., "March 14, 2025") + if "," in dates and "-" not in dates: + try: + return parser.parse(dates).date() + except ValueError: + return None + + # Handle date ranges (e.g., "May 26-30, 2025" or "November 2-6, 2026") + if "-" in dates and "," in dates: + try: + # Split the date range into parts + date_range, year = dates.split(", ") + month_day_range = date_range.split() + + # Extract month and day range + month = month_day_range[0] + day_range = month_day_range[1] + + # Extract end day from the range + end_day = int(day_range.split("-")[-1]) + + # Parse the year + year = int(year.strip()) + + # Use the start_date to determine the month if provided + if start_date: + start_date_parsed = start_date + month = start_date_parsed.strftime("%B") # Full month name (e.g., "May") + + # Parse the full end date string + end_date_str = f"{month} {end_day}, {year}" + return parser.parse(end_date_str).date() + except (ValueError, IndexError, AttributeError): + return None + + return None + @staticmethod - def update_data(gh_repository, repository, save=True): + def update_data(category, data, save=True): """Update event data.""" - key = gh_repository.name.lower() + key = slugify(data["name"]) try: event = Event.objects.get(key=key) except Event.DoesNotExist: event = Event(key=key) - event.from_github(repository) + event.from_dict(category, data) if save: event.save() return event + + def from_dict(self, category, data): + """Update instance based on the dict data.""" + fields = { + "category": { + "AppSec Days": Event.Category.APPSEC_DAYS, + "Global": Event.Category.GLOBAL, + "Partner": Event.Category.PARTNER, + }.get(category, Event.Category.OTHER), + "description": data.get("optional-text", ""), + "end_date": Event.parse_dates(data.get("dates", ""), data.get("start-date")), + "name": data["name"], + "start_date": parser.parse(data["start-date"]).date() + if isinstance(data["start-date"], str) + else data["start-date"], + "url": normalize_url(data.get("url", "")) or "", + } + + for key, value in fields.items(): + setattr(self, key, value) diff --git a/backend/apps/slack/commands/events.py b/backend/apps/slack/commands/events.py index 362251dcc..6819614c6 100644 --- a/backend/apps/slack/commands/events.py +++ b/backend/apps/slack/commands/events.py @@ -2,10 +2,10 @@ from django.conf import settings -from apps.common.constants import NL +from apps.common.constants import NL, OWASP_WEBSITE_URL from apps.slack.apps import SlackConfig from apps.slack.blocks import markdown -from apps.slack.utils import get_text +from apps.slack.utils import get_events_data, get_text COMMAND = "/events" @@ -17,9 +17,51 @@ def events_handler(ack, command, client): if not settings.SLACK_COMMANDS_ENABLED: return - blocks = [ - markdown(f"Please visit page{NL}"), - ] + events_data = get_events_data() + + valid_events = [event for event in events_data if event.start_date] + sorted_events = sorted(valid_events, key=lambda x: x.start_date) + + categorized_events = {} + for event in sorted_events: + category = event.category or "Other" + if category not in categorized_events: + categorized_events[category] = { + "events": [], + } + categorized_events[category]["events"].append(event) + + blocks = [] + blocks.append(markdown("*Upcoming OWASP Events:*")) + blocks.append({"type": "divider"}) + + for category, category_data in categorized_events.items(): + blocks.append(markdown(f"*Category: {category.replace('_', ' ').title()}*")) + + for idx, event in enumerate(category_data["events"], 1): + if event.url: + block_text = f"*{idx}. <{event.url}|{event.name}>*{NL}" + else: + block_text = f"*{idx}. {event.name}*{NL}" + + block_text += f" Start Date: {event.start_date}{NL}" + + if event.end_date: + block_text += f" End Date: {event.end_date}{NL}" + + if event.description: + block_text += f"_{event.description}_{NL}" + + blocks.append(markdown(block_text)) + + blocks.append({"type": "divider"}) + + blocks.append( + markdown( + f"🔍 For more information about upcoming events, " + f"please visit <{OWASP_WEBSITE_URL}/events/|OWASP Events>{NL}" + ) + ) conversation = client.conversations_open(users=command["user_id"]) client.chat_postMessage( diff --git a/backend/apps/slack/utils.py b/backend/apps/slack/utils.py index 79c63b349..84ddbed38 100644 --- a/backend/apps/slack/utils.py +++ b/backend/apps/slack/utils.py @@ -8,6 +8,7 @@ import requests import yaml +from django.utils import timezone from lxml import html from requests.exceptions import RequestException @@ -83,6 +84,17 @@ def get_staff_data(timeout=30): logger.exception("Unable to parse OWASP staff data file", extra={"file_path": file_path}) +def get_events_data(): + """Get raw events data via GraphQL.""" + from apps.owasp.models.event import Event + + try: + return Event.objects.filter(start_date__gte=timezone.now()).order_by("start_date") + except Exception as e: + logger.exception("Failed to fetch events data via database", extra={"error": str(e)}) + return None + + def get_text(blocks): """Convert blocks to plain text.""" text = [] diff --git a/backend/poetry.lock b/backend/poetry.lock index 3d7e084ad..c58641009 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -2915,4 +2915,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "826daea6bad6281b7716210d47a83bcf408a6e0ab48e97acc71207712e596fb8" +content-hash = "321bab241f908fcedf7aa3c9bedc87c62e5f49df60b955ed2f53cdee31dc8c64" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 493882762..5bc4eb439 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -39,6 +39,7 @@ openai = "^1.60.1" psycopg = "^3.2.4" pygithub = "^2.5.0" python = "^3.13" +python-dateutil = "^2.9.0.post0" pyyaml = "^6.0.2" requests = "^2.32.3" sentry-sdk = { extras = ["django"], version = "^2.20.0" } diff --git a/backend/tests/github/management/commands/github_update_owasp_organization_test.py b/backend/tests/github/management/commands/github_update_owasp_organization_test.py index f34de214b..58b78ddb8 100644 --- a/backend/tests/github/management/commands/github_update_owasp_organization_test.py +++ b/backend/tests/github/management/commands/github_update_owasp_organization_test.py @@ -6,7 +6,6 @@ Chapter, Command, Committee, - Event, Project, Repository, ) @@ -52,22 +51,22 @@ def mock_gh_repo(): ( "www-project-test", 0, - {"project": 1, "chapter": 0, "committee": 0, "event": 0}, + {"project": 1, "chapter": 0, "committee": 0}, ), ( "www-chapter-test", 0, - {"project": 0, "chapter": 1, "committee": 0, "event": 0}, + {"project": 0, "chapter": 1, "committee": 0}, ), ( "www-committee-test", 0, - {"project": 0, "chapter": 0, "committee": 1, "event": 0}, + {"project": 0, "chapter": 0, "committee": 1}, ), ( "www-event-test", 0, - {"project": 0, "chapter": 0, "committee": 0, "event": 1}, + {"project": 0, "chapter": 0, "committee": 0}, ), (None, 0, {"project": 1, "chapter": 1, "committee": 1, "event": 1}), (None, 1, {"project": 0, "chapter": 1, "committee": 1, "event": 1}), @@ -99,7 +98,6 @@ def create_mock_repo(name): create_mock_repo("www-project-test"), create_mock_repo("www-chapter-test"), create_mock_repo("www-committee-test"), - create_mock_repo("www-event-test"), create_mock_repo("www-other-test"), ] @@ -131,27 +129,22 @@ def __getitem__(self, index): mock.patch.object(Project, "bulk_save") as mock_project_bulk_save, mock.patch.object(Chapter, "bulk_save") as mock_chapter_bulk_save, mock.patch.object(Committee, "bulk_save") as mock_committee_bulk_save, - mock.patch.object(Event, "bulk_save") as mock_event_bulk_save, mock.patch.object(Project, "update_data") as mock_project_update, mock.patch.object(Chapter, "update_data") as mock_chapter_update, mock.patch.object(Committee, "update_data") as mock_committee_update, - mock.patch.object(Event, "update_data") as mock_event_update, mock.patch.object(Project, "objects") as mock_project_objects, mock.patch.object(Chapter, "objects") as mock_chapter_objects, mock.patch.object(Committee, "objects") as mock_committee_objects, - mock.patch.object(Event, "objects") as mock_event_objects, mock.patch.object(Repository, "objects") as mock_repository_objects, mock.patch("builtins.print") as mock_print, ): mock_project_update.return_value = mock_repository mock_chapter_update.return_value = mock_repository mock_committee_update.return_value = mock_repository - mock_event_update.return_value = mock_repository mock_project_objects.all.return_value = [] mock_chapter_objects.all.return_value = [] mock_committee_objects.all.return_value = [] - mock_event_objects.all.return_value = [] mock_repository_objects.filter.return_value.count.return_value = 1 command.handle(repository=repository_name, offset=offset) @@ -173,12 +166,9 @@ def __getitem__(self, index): assert mock_chapter_update.call_count == expected_calls["chapter"] elif repository_name.startswith("www-committee-"): assert mock_committee_update.call_count == expected_calls["committee"] - elif repository_name.startswith("www-event-"): - assert mock_event_update.call_count == expected_calls["event"] else: assert mock_print.call_count > 0 mock_project_bulk_save.assert_called_once() mock_chapter_bulk_save.assert_called_once() mock_committee_bulk_save.assert_called_once() - mock_event_bulk_save.assert_called_once() diff --git a/backend/tests/owasp/api/event_test.py b/backend/tests/owasp/api/event_test.py index 8982502b1..f2a6adb36 100644 --- a/backend/tests/owasp/api/event_test.py +++ b/backend/tests/owasp/api/event_test.py @@ -11,8 +11,6 @@ "name": "Test Event", "description": "A test event", "url": "https://github.com/owasp/Nest", - "created_at": "2024-11-01T00:00:00Z", - "updated_at": "2024-07-02T00:00:00Z", }, True, ), @@ -21,8 +19,6 @@ "name": "biggest event", "description": "this is a biggest event", "url": "https://github.com/owasp", - "created_at": "2023-12-01T00:00:00Z", - "updated_at": "2023-09-02T00:00:00Z", }, True, ), diff --git a/backend/tests/owasp/models/event_test.py b/backend/tests/owasp/models/event_test.py index 5acd007b9..55cbba9f5 100644 --- a/backend/tests/owasp/models/event_test.py +++ b/backend/tests/owasp/models/event_test.py @@ -1,8 +1,8 @@ +from datetime import date from unittest.mock import Mock, patch import pytest -from apps.github.models.repository import Repository from apps.owasp.models.event import Event @@ -16,59 +16,14 @@ class TestEventModel: ], ) def test_event_str(self, name, key, expected_str): - event = Event(name=name, key=key) + event = Event(name=name, key=key, start_date=date(2025, 1, 1)) assert str(event) == expected_str - def test_from_github(self): - repository_mock = Repository() - repository_mock.name = "Test Repo" - repository_mock.created_at = "2024-01-01" - repository_mock.updated_at = "2024-12-24" - repository_mock.title = "Nest" - repository_mock.pitch = "Nest Pitch" - repository_mock.tags = ["react", "python"] - - event = Event() - - with patch( - "apps.owasp.models.event.RepositoryBasedEntityModel.from_github" - ) as mock_from_github: - mock_from_github.side_effect = lambda instance, _, repo: setattr( - instance, "name", repo.title - ) - - event.from_github(repository_mock) - - assert event.owasp_repository == repository_mock - - mock_from_github.assert_called_once_with( - event, - { - "description": "pitch", - "level": "level", - "name": "title", - "tags": "tags", - }, - repository_mock, - ) - - assert event.name == repository_mock.title - def test_bulk_save(self): - mock_event = [Mock(id=None), Mock(id=1)] + mock_event = [ + Mock(id=None, start_date=date(2025, 1, 1)), + Mock(id=1, start_date=date(2025, 1, 1)), + ] with patch("apps.owasp.models.event.BulkSaveModel.bulk_save") as mock_bulk_save: Event.bulk_save(mock_event, fields=["name"]) mock_bulk_save.assert_called_once_with(Event, mock_event, fields=["name"]) - - @patch("apps.owasp.models.event.Event.objects.get") - def test_update_data_event_does_not_exist(self, mock_get): - mock_get.side_effect = Event.DoesNotExist - gh_repository_mock = Mock() - gh_repository_mock.name = "new_repo" - repository_mock = Repository() - - with patch.object(Event, "save", return_value=None) as mock_save: - event = Event.update_data(gh_repository_mock, repository_mock, save=True) - mock_save.assert_called_once() - assert event.key == "new_repo" - assert event.owasp_repository == repository_mock diff --git a/backend/tests/owasp/models/project_test.py b/backend/tests/owasp/models/project_test.py index 9b2539c0e..45fd3b387 100644 --- a/backend/tests/owasp/models/project_test.py +++ b/backend/tests/owasp/models/project_test.py @@ -76,8 +76,19 @@ def test_deactivate(self, mock_save): mock_save.assert_called_once_with(update_fields=("is_active",)) @patch("apps.owasp.models.project.Project.objects.get") - def test_update_data_project_does_not_exist(self, mock_get): + @patch("apps.github.utils.requests.get") + def test_update_data_project_does_not_exist(self, mock_requests_get, mock_get): + """Test updating project data when the project doesn't exist.""" mock_get.side_effect = Project.DoesNotExist + + mock_response = Mock() + mock_response.text = """ + # Project Title + Some project description + """ + mock_requests_get.return_value = mock_response + + # Setup test data gh_repository_mock = Mock() gh_repository_mock.name = "new_repo" repository_mock = Repository() diff --git a/backend/tests/slack/commands/events_test.py b/backend/tests/slack/commands/events_test.py new file mode 100644 index 000000000..efe6a4d8b --- /dev/null +++ b/backend/tests/slack/commands/events_test.py @@ -0,0 +1,123 @@ +"""Test events command handler.""" + +from unittest.mock import MagicMock, patch + +import pytest +from django.conf import settings + +from apps.slack.commands.events import events_handler + + +# Define a mock event class to simulate the new event object structure +class MockEvent: + def __init__(self, name, category, start_date, end_date, url, description): + self.name = name + self.category = category + self.start_date = start_date + self.end_date = end_date + self.url = url + self.description = description + + +# Mock event data as objects +mock_events = [ + MockEvent( + name="OWASP Snow 2025", + category="AppSec Days", + start_date="2025-03-14", + end_date="March 14, 2025", + url="https://example.com/snow", + description="Regional conference", + ), + MockEvent( + name="OWASP Global AppSec EU 2025", + category="Global", + start_date="2025-05-26", + end_date="May 26-30, 2025", + url="https://example.com/eu", + description="Premier conference", + ), +] + + +class TestEventsHandler: + """Test events command handler.""" + + @pytest.fixture() + def mock_slack_command(self): + return { + "user_id": "U123456", + } + + @pytest.fixture() + def mock_slack_client(self): + client = MagicMock() + client.conversations_open.return_value = {"channel": {"id": "C123456"}} + return client + + @pytest.mark.parametrize( + ("commands_enabled", "has_events_data", "expected_header"), + [ + (False, True, None), + (True, True, "*Upcoming OWASP Events:*"), + (True, False, "*Upcoming OWASP Events:*"), + ], + ) + @patch("apps.slack.commands.events.get_events_data") + def test_handler_responses( + self, + mock_get_events_data, + commands_enabled, + has_events_data, + expected_header, + mock_slack_client, + mock_slack_command, + ): + """Test handler responses.""" + settings.SLACK_COMMANDS_ENABLED = commands_enabled + mock_get_events_data.return_value = mock_events if has_events_data else [] + + events_handler(ack=MagicMock(), command=mock_slack_command, client=mock_slack_client) + + if not commands_enabled: + mock_slack_client.conversations_open.assert_not_called() + mock_slack_client.chat_postMessage.assert_not_called() + return + + mock_slack_client.conversations_open.assert_called_once_with( + users=mock_slack_command["user_id"] + ) + + blocks = mock_slack_client.chat_postMessage.call_args[1]["blocks"] + + assert blocks[0]["text"]["text"] == expected_header + assert blocks[1]["type"] == "divider" + + if has_events_data: + current_block = 2 + + assert "*Category: Appsec Days*" in blocks[current_block]["text"]["text"] + current_block += 1 + + event_block = blocks[current_block]["text"]["text"] + assert "*1. *" in event_block + assert "Start Date: 2025-03-14" in event_block + assert "End Date: March 14, 2025" in event_block + assert "_Regional conference_" in event_block + current_block += 1 + + assert blocks[current_block]["type"] == "divider" + current_block += 1 + + assert "*Category: Global*" in blocks[current_block]["text"]["text"] + current_block += 1 + + event_block = blocks[current_block]["text"]["text"] + assert "*1. *" in event_block + assert "Start Date: 2025-05-26" in event_block + assert "End Date: May 26-30, 2025" in event_block + assert "_Premier conference_" in event_block + current_block += 1 + + footer_block = blocks[-1]["text"]["text"] + assert "🔍 For more information about upcoming events" in footer_block