From 73ea36b7a86daeee4fe56b38f1d0225789105385 Mon Sep 17 00:00:00 2001 From: Lacey Williams Henschel Date: Thu, 14 Dec 2023 13:43:00 -0800 Subject: [PATCH] Pull in calendar data from the Google calendar - Adds functions to retrieve raw events from the Google Calendar API, extract the data we need for those events, and order those events by month for display - Documents the new env vars - Adds new setting for the google calendar API url - Adds `/calendar/` to URLs, which displays the rendered google calendar **but didn't prettify it** - Adds events to homepage, using the existing template - Edits to the homepage template to show the events and make the paging arrows work --- ak/views.py | 24 ++++++++ config/settings.py | 4 ++ config/urls.py | 3 + core/calendar.py | 92 +++++++++++++++++++++++++++ core/tests/test_calendar.py | 111 +++++++++++++++++++++++++++++++++ core/tests/test_views.py | 5 ++ core/views.py | 8 +++ docs/env_vars.md | 6 ++ env.template | 2 + templates/calendar.html | 15 +++++ templates/homepage.html | 120 +++++++++++++++--------------------- 11 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 core/calendar.py create mode 100644 core/tests/test_calendar.py create mode 100644 templates/calendar.html diff --git a/ak/views.py b/ak/views.py index 124d4cb8..0639aab3 100644 --- a/ak/views.py +++ b/ak/views.py @@ -1,4 +1,5 @@ import requests +import structlog from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse from django.shortcuts import render @@ -6,11 +7,15 @@ from django.views import View from django.views.generic import TemplateView from config.settings import JDOODLE_API_CLIENT_ID, JDOODLE_API_CLIENT_SECRET +from core.calendar import extract_calendar_events, events_by_month, get_calendar from libraries.models import Category, Library from news.models import Entry from versions.models import Version +logger = structlog.get_logger() + + class HomepageView(TemplateView): """ Our default homepage for temp-site. We expect you to not use this view @@ -25,8 +30,27 @@ class HomepageView(TemplateView): latest_version = Version.objects.most_recent() context["latest_version"] = latest_version context["featured_library"] = self.get_featured_library(latest_version) + context["events"] = self.get_events() + if context["events"]: + context["num_months"] = len(context["events"]) + else: + context["num_months"] = 0 return context + def get_events(self): + try: + raw_event_data = get_calendar() + except Exception: + logger.info("Error getting events") + return + + if not raw_event_data: + return + + events = extract_calendar_events(raw_event_data) + sorted_events = events_by_month(events) + return dict(sorted_events) + def get_featured_library(self, latest_version): library = Library.objects.filter(featured=True).first() diff --git a/config/settings.py b/config/settings.py index 6e3c0016..d1240689 100755 --- a/config/settings.py +++ b/config/settings.py @@ -462,3 +462,7 @@ ARTIFACTORY_URL = env( # The min Boost version is the oldest version of Boost that our import scripts # will retrieve. It's determined by the files we store in the archives/ in S3. MINIMUM_BOOST_VERSION = "1.31.0" + +# Boost Google Calendar +BOOST_CALENDAR = "5rorfm42nvmpt77ac0vult9iig@group.calendar.google.com" +CALENDAR_API_KEY = env("CALENDAR_API_KEY", default="changeme") diff --git a/config/urls.py b/config/urls.py index f5cfded6..1701e8c0 100755 --- a/config/urls.py +++ b/config/urls.py @@ -14,6 +14,7 @@ from ak.views import ( OKView, ) from core.views import ( + CalendarView, ClearCacheView, DocLibsTemplateView, MarkdownTemplateView, @@ -113,6 +114,8 @@ urlpatterns = ( TemplateView.as_view(template_name="community_temp.html"), name="community", ), + # Boost community calendar + path("calendar/", CalendarView.as_view(), name="calendar"), # Boost versions views path("releases//", VersionDetail.as_view(), name="release-detail"), path( diff --git a/core/calendar.py b/core/calendar.py new file mode 100644 index 00000000..80831e9e --- /dev/null +++ b/core/calendar.py @@ -0,0 +1,92 @@ +import datetime +from django.conf import settings +import requests +from collections import defaultdict + +from dateutil.parser import parse + + +def get_calendar(min_time=None, single_events=True, order_by="startTime"): + """Retrieves JSON response for the Boost Google Calendar. + + Args: + - min_time: The minimum start time to retrieve the calendar. Default is "now," + which means the default behavior is to return only future events. + ISO format. + - single_events: True allows us to retrieve recurring events as individual events + - order_by: The order in which to retrieve and return the events + + See the docs for more information on query params available to this API: + https://developers.google.com/calendar/api/v3/reference/events/list + """ + if not min_time: + min_time = ( + datetime.datetime.utcnow().isoformat() + "Z" + ) # 'Z' indicates UTC time + + url = f"https://www.googleapis.com/calendar/v3/calendars/{settings.BOOST_CALENDAR}/events?key={settings.CALENDAR_API_KEY}&timeMin={min_time}&singleEvents={single_events}&orderBy={order_by}" + + headers = {"Accept": "application/json"} + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + + +def extract_calendar_events(calendar_data, count=50): + """Take the response from get_calendar() and extract the next N + events, where N is the count argument. + + Args: + - calendar_data: The response from the Google Calendar events API + - count: The number of events to extract + """ + event_data = calendar_data.get("items") + + if not event_data: + return [] + + # Don't get an IndexError + if len(event_data) < count: + events = event_data + else: + events = event_data[:count] + + return_data = [] + + for event in events: + start_date = event.get("start") + start_raw = start_date["date"] if start_date else None + try: + start = parse(start_raw) + except ValueError: + start = None + + end_date = event.get("end") + end_raw = end_date["date"] if end_date else None + try: + end = parse(end_raw) + except ValueError: + end = None + + event_dict = { + "start": start, + "end": end, + "name": event.get("summary"), + "description": event.get("description"), + } + return_data.append(event_dict) + + return return_data + + +def events_by_month(events): + """Takes the events returned by extract_calendar_events + and returns them organized in a dictionary by month and year + for display purposes. + """ + events_by_month = defaultdict(list) + for event in events: + month_year = event["start"].strftime("%B %Y") + events_by_month[month_year].append(event) + + return events_by_month diff --git a/core/tests/test_calendar.py b/core/tests/test_calendar.py new file mode 100644 index 00000000..f3206da6 --- /dev/null +++ b/core/tests/test_calendar.py @@ -0,0 +1,111 @@ +from datetime import datetime +from dateutil.parser import parse +from ..calendar import extract_calendar_events, events_by_month + + +def test_extract_calendar_events(): + mock_calendar_data = { + "items": [ + { + "start": {"date": "2024-02-21"}, + "end": {"date": "2024-02-22"}, + "summary": "Event 1", + "description": "Description 1", + }, + { + "start": {"date": "2024-02-28"}, + "end": {"date": "2024-03-01"}, + "summary": "Event 2", + "description": "Description 2", + }, + ] + } + + expected_output = [ + { + "start": parse("2024-02-21"), + "end": parse("2024-02-22"), + "name": "Event 1", + "description": "Description 1", + }, + { + "start": parse("2024-02-28"), + "end": parse("2024-03-01"), + "name": "Event 2", + "description": "Description 2", + }, + ] + assert extract_calendar_events(mock_calendar_data, count=2) == expected_output + + +def test_extract_calendar_events_empty_data(): + # Test for empty calendar data + result = extract_calendar_events({"items": []}) + assert result == [] + + +def test_extract_calendar_events_less_than_count(): + # Test for the case where the number of events is less than the requested count + mock_calendar_data = { + "items": [ + { + "start": {"date": "2024-02-21"}, + "end": {"date": "2024-02-22"}, + "summary": "Event 1", + "description": "Description 1", + } + ] + } + expected_output = [ + { + "start": parse("2024-02-21"), + "end": parse("2024-02-22"), + "name": "Event 1", + "description": "Description 1", + } + ] + assert extract_calendar_events(mock_calendar_data, count=2) == expected_output + + +def test_events_by_month(): + input_events = [ + { + "start": datetime(2024, 2, 21), + "end": datetime(2024, 2, 22), + "name": "Event1", + "description": "Desc1", + }, + { + "start": datetime(2024, 3, 1), + "end": datetime(2024, 3, 2), + "name": "Event2", + "description": "Desc2", + }, + ] + + expected = { + "February 2024": [ + { + "start": datetime(2024, 2, 21), + "end": datetime(2024, 2, 22), + "name": "Event1", + "description": "Desc1", + } + ], + "March 2024": [ + { + "start": datetime(2024, 3, 1), + "end": datetime(2024, 3, 2), + "name": "Event2", + "description": "Desc2", + } + ], + } + assert events_by_month(input_events) == expected + + +def test_events_by_month_no_events(): + input_events = [] + + expected = {} + assert events_by_month(input_events) == expected diff --git a/core/tests/test_views.py b/core/tests/test_views.py index 0fd1f09e..ea86689f 100644 --- a/core/tests/test_views.py +++ b/core/tests/test_views.py @@ -246,3 +246,8 @@ def test_docs_libs_gateway_200_html_transformed(rf, tp, mock_get_file_data): """ tp.assertResponseNotContains(legacy_body, response) + + +def test_calendar(rf, tp): + response = tp.get("calendar") + tp.response_200(response) diff --git a/core/views.py b/core/views.py index 35e80e47..b0439c72 100644 --- a/core/views.py +++ b/core/views.py @@ -28,6 +28,14 @@ from .tasks import ( logger = structlog.get_logger() +class CalendarView(TemplateView): + template_name = "calendar.html" + + def get(self, request, *args, **kwargs): + context = {"boost_calendar": settings.BOOST_CALENDAR} + return self.render_to_response(context) + + class ClearCacheView(UserPassesTestMixin, View): http_method_names = ["get"] login_url = "/login/" diff --git a/docs/env_vars.md b/docs/env_vars.md index eabd1460..dba7d825 100644 --- a/docs/env_vars.md +++ b/docs/env_vars.md @@ -35,3 +35,9 @@ This project uses environment variables to configure certain aspects of the appl - Specifies the name of the Amazon S3 bucket where static content is stored - For **local development**, obtain valid value from the Boost team. - In **deployed environments**, the valid value is set in `kube/boost/values.yaml` (or the environment-specific yaml file). + +## `CALENDAR_API_KEY` + +- API key for the Boost Google calendar +- For **local development**, obtain valid value from the Boost team. +- In **deployed environments**, the valid value is set in `kube/boost/values.yaml` (or the environment-specific yaml file). diff --git a/env.template b/env.template index da35cf1e..6923288d 100644 --- a/env.template +++ b/env.template @@ -33,3 +33,5 @@ SERVE_FROM_DOMAIN=localhost # Celery settings CELERY_BROKER=redis://redis:6379/0 CELERY_BACKEND=redis://redis:6379/0 + +CALENDAR_API_KEY=changeme diff --git a/templates/calendar.html b/templates/calendar.html new file mode 100644 index 00000000..180252c2 --- /dev/null +++ b/templates/calendar.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Boost Calendar{% endblock %} + +{% block content_wrapper %} +
+

Boost Calendar

+
+
+
+

Below is a community maintained calendar of Boost related events. The release managers try to keep the release related portion of the calendar up to date.

+ +
+
+{% endblock %} diff --git a/templates/homepage.html b/templates/homepage.html index 4318d25b..215131b8 100644 --- a/templates/homepage.html +++ b/templates/homepage.html @@ -60,83 +60,59 @@ -
-
-
-
- schedule of events -
-
-
-
- - - {% comment %}
-
-

November 2023

- - Wednesday November 1st: Boost 1.84.0 closed for major changes -
-
Release closed for major code changes. Still open for serious problem fixes and docs changes without release manager review.
-
-
- - Wednesday November 8th: Boost 1.84.0 closed for beta -
-
Release closed for all changes.
-
-
- - Wednesday November 15th: Boost 1.84.0 beta -
-
Beta posted for download.
-
-
- - Thursday November 16th: Boost 1.84.0 open for bug fixes -
-
Release open for bug fixes and documentation updates. Other changes by permission of a release manager.
-
-
-
-
{% endcomment %} -
-
-

December 2023

- - Wednesday December 6th: Boost 1.84.0 closed -
-
Release closed for all changes.
-
-
- - Wednesday November 8th: Boost 1.84.0 release -
-
Release posted for download.
-
-
+ {% if events %} +
+
+
+
+ schedule of events
- - - {% comment %} -
-
- +
+
+ + {% for month_year, month_events in events.items %} +
+
+

{{ month_year }}

+ {% for event in month_events %} + + {{ event.start.date }}: {{ event.name }} +
+
{{ event.description }}
+
+
+ {% endfor %} +
+
+ {% endfor %} + +
+
+ +
+
+ +
+
-
- -
-
{% endcomment %} +
-
-
+ {% endif %} + {% if featured_library %}