Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/django_project/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,14 @@
# 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)
LINKEDIN_CLIENT_ID: str | None = env.str("LINKEDIN_CLIENT_ID", None)
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)
66 changes: 66 additions & 0 deletions src/django_project/tests/unit/web/test_eventbrite_scraper.py
Original file line number Diff line number Diff line change
@@ -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,
)
16 changes: 15 additions & 1 deletion src/django_project/tests/unit/web/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
5 changes: 4 additions & 1 deletion src/django_project/web/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
11 changes: 7 additions & 4 deletions src/django_project/web/utilities/scrapers/eventbrite.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import random
import time
from datetime import datetime, timedelta
Expand Down Expand Up @@ -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 <age> days
list: list of Eventbrite events starting in the next <age> 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}"}
Expand Down
Loading