diff --git a/src/django_project/core/settings.py b/src/django_project/core/settings.py index 1a1ff2f..8cb6e19 100644 --- a/src/django_project/core/settings.py +++ b/src/django_project/core/settings.py @@ -288,6 +288,7 @@ # third party integrations DISCORD_WEBHOOK_URL: str | None = env.str("DISCORD_WEBHOOK_URL", None) # for the general channel EVENTBRITE_API_KEY: str | None = env.str("EVENTBRITE_API_KEY", None) +EVENTBRITE_EVENT_LOOKAHEAD_DAYS: int = env.int("EVENTBRITE_EVENT_LOOKAHEAD_DAYS", 60) GEMINI_API_KEY: str | None = env.str("GEMINI_API_KEY", None) GOOGLE_ANALYTICS_TAG_ID: str | None = env.str("GOOGLE_ANALYTICS_TAG_ID", None) LINKEDIN_ACCESS_TOKEN: str | None = env.str("LINKEDIN_ACCESS_TOKEN", None) @@ -295,5 +296,6 @@ LINKEDIN_CLIENT_SECRET: str | None = env.str("LINKEDIN_CLIENT_SECRET", None) LINKEDIN_ORGANIZATION_URN: str | None = env.str("LINKEDIN_ORGANIZATION_URN", None) LINKEDIN_REFRESH_TOKEN: str | None = env.str("LINKEDIN_REFRESH_TOKEN", None) +POST_TO_LINKEDIN: bool = env.bool("POST_TO_LINKEDIN", False) SPUG_API_TOKEN: str | None = env.str("SPUG_API_TOKEN", None) SPUG_API_URL: str | None = env.str("SPUG_API_URL", None) diff --git a/src/django_project/tests/unit/web/test_eventbrite_scraper.py b/src/django_project/tests/unit/web/test_eventbrite_scraper.py new file mode 100644 index 0000000..e9ec2b9 --- /dev/null +++ b/src/django_project/tests/unit/web/test_eventbrite_scraper.py @@ -0,0 +1,66 @@ +import os +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import Mock, patch + +import django + +BASE_DIR = Path(__file__).parents[4] +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") +os.environ.setdefault("ENV_PATH", f"{BASE_DIR}/envs/.env.test") + +django.setup() + +from web.utilities.scrapers.eventbrite import get_events_for_organization + + +class TestGetEventsForOrganization: + @patch("web.utilities.scrapers.eventbrite.requests.get") + @patch("web.utilities.scrapers.eventbrite.timezone.now") + def test_uses_configured_lookahead_window(self, mock_now, mock_get): + mock_now.return_value = datetime(2026, 8, 21, 7, 11, 15, tzinfo=UTC) + response = Mock() + response.json.return_value = {"events": [{"id": "evt_123"}]} + response.raise_for_status.return_value = None + mock_get.return_value = response + + with patch("web.utilities.scrapers.eventbrite.settings.EVENTBRITE_API_KEY", "token", create=True): + with patch( + "web.utilities.scrapers.eventbrite.settings.EVENTBRITE_EVENT_LOOKAHEAD_DAYS", + 60, + create=True, + ): + events = get_events_for_organization("637389713") + + assert events == [{"id": "evt_123"}] + mock_get.assert_called_once_with( + "https://www.eventbriteapi.com/v3/organizers/637389713/events/" + "?start_date.range_start=2026-08-21T07:11:15Z&start_date.range_end=2026-10-20T07:11:15Z", + headers={"Authorization": "Bearer token"}, + timeout=15, + ) + + @patch("web.utilities.scrapers.eventbrite.requests.get") + @patch("web.utilities.scrapers.eventbrite.timezone.now") + def test_explicit_age_overrides_configured_lookahead(self, mock_now, mock_get): + mock_now.return_value = datetime(2026, 8, 21, 7, 11, 15, tzinfo=UTC) + response = Mock() + response.json.return_value = {"events": [{"id": "evt_456"}]} + response.raise_for_status.return_value = None + mock_get.return_value = response + + with patch("web.utilities.scrapers.eventbrite.settings.EVENTBRITE_API_KEY", "token", create=True): + with patch( + "web.utilities.scrapers.eventbrite.settings.EVENTBRITE_EVENT_LOOKAHEAD_DAYS", + 60, + create=True, + ): + events = get_events_for_organization("637389713", age=14) + + assert events == [{"id": "evt_456"}] + mock_get.assert_called_once_with( + "https://www.eventbriteapi.com/v3/organizers/637389713/events/" + "?start_date.range_start=2026-08-21T07:11:15Z&start_date.range_end=2026-09-04T07:11:15Z", + headers={"Authorization": "Bearer token"}, + timeout=15, + ) diff --git a/src/django_project/tests/unit/web/test_tasks.py b/src/django_project/tests/unit/web/test_tasks.py index 39d6c5b..d31eaf0 100644 --- a/src/django_project/tests/unit/web/test_tasks.py +++ b/src/django_project/tests/unit/web/test_tasks.py @@ -12,7 +12,7 @@ django.setup() from model_bakery import baker from web.models import Event -from web.tasks import ingest_future_eventbrite_events +from web.tasks import ingest_future_eventbrite_events, post_event_to_linkedin class TestIngestFutureEventbriteEvents(TestCase): @@ -75,3 +75,17 @@ def test_ingests_event_with_null_primary_venue_address(self, mock_get_events_for self.assertEqual(event.location_name, "Online") self.assertEqual(event.location_address, "") self.assertEqual(event.map_link, "") + + +class TestPostEventToLinkedIn(TestCase): + def test_skips_when_post_to_linkedin_setting_is_false(self): + event = baker.make("web.Event") + + with ( + patch("web.tasks.settings.POST_TO_LINKEDIN", False), + patch("web.tasks.LinkedInOrganizationClient") as mock_linkedin_client, + ): + result = post_event_to_linkedin(event.pk, is_new=True) + + self.assertEqual(result, f"POST_TO_LINKEDIN is False. Skipping LinkedIn post for event with pk {event.pk}.") + mock_linkedin_client.assert_not_called() diff --git a/src/django_project/web/tasks.py b/src/django_project/web/tasks.py index 2806dfc..d5a5bc1 100644 --- a/src/django_project/web/tasks.py +++ b/src/django_project/web/tasks.py @@ -204,12 +204,15 @@ def post_event_to_linkedin(event_pk: int, is_new: bool) -> str: if not event: return f"Event with pk {event_pk} not found." + if not settings.POST_TO_LINKEDIN: + return f"POST_TO_LINKEDIN is False. Skipping LinkedIn post for event with pk {event_pk}." + linkedin_credential = IntegrationCredential.objects.filter(provider="linkedin").first() access_token = linkedin_credential.access_token if linkedin_credential else settings.LINKEDIN_ACCESS_TOKEN refresh_token = linkedin_credential.refresh_token if linkedin_credential else settings.LINKEDIN_REFRESH_TOKEN if not settings.LINKEDIN_ORGANIZATION_URN: - return "LinkedIn organization URN not configured in settings. Skipping post." + return f"LinkedIn organization URN not configured in settings. Skipping post for event with pk {event_pk}." if not access_token and not (refresh_token and settings.LINKEDIN_CLIENT_ID and settings.LINKEDIN_CLIENT_SECRET): return "LinkedIn API credentials not configured in settings. Skipping post." diff --git a/src/django_project/web/utilities/scrapers/eventbrite.py b/src/django_project/web/utilities/scrapers/eventbrite.py index deca1ae..a2f5af2 100644 --- a/src/django_project/web/utilities/scrapers/eventbrite.py +++ b/src/django_project/web/utilities/scrapers/eventbrite.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import random import time from datetime import datetime, timedelta @@ -63,20 +65,21 @@ def get_organization_details(organization_id: str) -> dict: return response.json()["organizers"][0] -def get_events_for_organization(organization_id: str, age: int = 14) -> list: +def get_events_for_organization(organization_id: str, age: int | None = None) -> list: """get a list of events for a given Eventbrite organization Args: organization_id (str): Eventbrite organization identifier - age (int): number of days ago when events were created + age (int | None): number of days ahead to search for upcoming events Returns: - list: list of Eventbrite events created in the past days + list: list of Eventbrite events starting in the next days """ api_token: str | None = getattr(settings, "EVENTBRITE_API_KEY", None) if not api_token: return [] - start_date_range_end: str = (timezone.now() + timedelta(days=age)).strftime("%Y-%m-%dT%H:%M:%SZ") + lookahead_days = age if age is not None else getattr(settings, "EVENTBRITE_EVENT_LOOKAHEAD_DAYS", 60) + start_date_range_end: str = (timezone.now() + timedelta(days=lookahead_days)).strftime("%Y-%m-%dT%H:%M:%SZ") start_date_range_start: str = timezone.now().strftime("%Y-%m-%dT%H:%M:%SZ") url: str = f"https://www.eventbriteapi.com/v3/organizers/{organization_id}/events/?start_date.range_start={start_date_range_start}&start_date.range_end={start_date_range_end}" headers: dict[str, str] = {"Authorization": f"Bearer {api_token}"}