diff --git a/.env.template b/.env.template
index 183571d..78ff702 100644
--- a/.env.template
+++ b/.env.template
@@ -1,8 +1,8 @@
CALENDAR_URL=
CALENDAR_OUTLOOK_DAYS=
CALENDAR_EVENT_MAXIMUM=
+CALENDAR_CACHE_REFRESH=
CALENDAR_TIMEZONE=
-CALENDAR_API_KEY=
WATCHED_CHANNELS=
SLACK_API_TOKEN=
SLACK_JUMPSTART_MESSAGE=
\ No newline at end of file
diff --git a/src/api/endpoints.py b/src/api/endpoints.py
index 151e980..11b4af7 100644
--- a/src/api/endpoints.py
+++ b/src/api/endpoints.py
@@ -4,6 +4,7 @@
import httpx
import random
import textwrap
+import asyncio
from fastapi import APIRouter, Request, Form
from fastapi.responses import JSONResponse
@@ -23,8 +24,8 @@ def get_calendar() -> JSONResponse:
JSONResponse: A JSON response containing the calendar data.
"""
- get_future_events_ical: list[cshcalendar.CalendarInfo] = (
- cshcalendar.get_future_events_ical()
+ get_future_events_ical: tuple[cshcalendar.CalendarInfo] = asyncio.run(
+ cshcalendar.get_future_events()
)
formatted_events: dict = cshcalendar.format_events(get_future_events_ical)
diff --git a/src/config.py b/src/config.py
index 9a02244..7c45f0b 100644
--- a/src/config.py
+++ b/src/config.py
@@ -16,13 +16,10 @@
CALENDAR_OUTLOOK_DAYS: int = int(os.getenv("CALENDAR_OUTLOOK_DAYS", "7"))
CALENDAR_EVENT_MAXIMUM: int = int(os.getenv("CALENDAR_EVENT_MAXIMUM", "10"))
CALENDAR_TIMEZONE: str = os.getenv("CALENDAR_TIMEZONE", "America/New_York")
-CALENDAR_API_KEY: str = os.getenv("CALENDAR_API_KEY", None)
+CALENDAR_CACHE_REFRESH: int = int(os.getenv("CALENDAR_CACHE_REFRESH", "10"))
if SLACK_API_TOKEN in (None, ""):
raise Exception("Missing SLACK_API_TOKEN")
-if CALENDAR_API_KEY in (None, "") and CALENDAR_URL in (None, ""):
- raise Exception("Missing CALENDAR_API_KEY or CALENDAR_URL")
-
with open(os.path.join(BASE_DIR, "static", "slack", "dm_request_template.json")) as f:
SLACK_DM_TEMPLATE = json.load(f)
diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py
index 8e82498..cd4c365 100644
--- a/src/core/cshcalendar.py
+++ b/src/core/cshcalendar.py
@@ -1,28 +1,37 @@
from logging import getLogger, Logger
-from datetime import datetime
-from datetime import date
-from datetime import timedelta
+from datetime import datetime, date, timedelta, time
from zoneinfo import ZoneInfo
-import time
-from googleapiclient.discovery import build
-from icalendar import Calendar
-import requests
+# from icalendar import Calendar
+from icalendar.cal import Event, Calendar
+import httpx
import recurring_ical_events
import arrow
-import config
+from config import (
+ CALENDAR_CACHE_REFRESH,
+ CALENDAR_EVENT_MAXIMUM,
+ CALENDAR_OUTLOOK_DAYS,
+ CALENDAR_TIMEZONE,
+ CALENDAR_URL,
+)
+import asyncio
+
+calendar_cache: tuple[CalendarInfo] = () # The current cache of the calendar
+cal_last_update: date | None = (
+ None # The last time the calendar was fetched and updated the cache
+)
+header_none_match: str | None = (
+ None # Used for the httpx.get to see if the current calendar matches
+)
+header_last_modified: str | None = (
+ None # Used for the httpx.get to see when the calndar was last modified
+)
+cal_currently_rebuilding: bool = False # Tells if the calendar is being rebuilt
logger: Logger = getLogger(__name__)
-operation_start_time = time.perf_counter()
-
logger.info("Starting up the calendar service!")
-try:
- calendar_service = build("calendar", "v3", developerKey=config.CALENDAR_API_KEY)
-except:
- logger.warning(
- "Failed to build the calendar service, check your API key and internet connection!"
- )
+cshcal_client = httpx.AsyncClient()
# Automatically format all info into the class
@@ -32,45 +41,68 @@ class CalendarInfo:
certain parts of the codebass
"""
- def __init__(self, name: str, date_time: date):
+ def __init__(self, name: str, date_time: date, location: str | None = None):
self.name: str = name
+
+ if isinstance(date_time, date) and not isinstance(date_time, datetime):
+ date_time = arrow.combine(
+ date_time, time.min, tzinfo=ZoneInfo(CALENDAR_TIMEZONE)
+ )
+ elif not date_time.tzinfo:
+ date_time = date_time.replace(tzinfo=ZoneInfo(CALENDAR_TIMEZONE))
self.date: arrow.arrow = arrow.get(date_time) # Arrow has way cooler stuff
+ self.location: str | None = location
+ def __eq__(self, other):
+ if not isinstance(other, CalendarInfo):
+ return False
+ return (self.name == other.name) and (self.date == self.date)
-def report_timing(display_tag: str) -> None:
- """
- Helper function to report how long an operation took since the lastly established operation start time.
+ def __hash__(self):
+ return (
+ hash(self.name) + hash(self.date)
+ ) # Might be stupid only hashing off name, but as of right now I think the implementation works
- Args:
- displayTag: The tag to be printed into the terminal.
- """
- operation_timestamp = time.perf_counter() - operation_start_time
- logger.info(f"{operation_timestamp}:: {display_tag}")
-
-
-def format_events(events: list[CalendarInfo]) -> dict:
+def format_events(events: tuple[CalendarInfo]) -> dict[str, str]:
"""
Formats a parsed list of CalendarInfos, and returns the HTML required for front end
Args:
- events: The list of CalendarInfos to be formatted
+ events: The list of CalendarInfos to be formatted
Returns:
- dict: Returns a dictionary with the "data" key mapping to the HTML data.
+ dict: Returns a dictionary with the "data" key mapping to the HTML data.
"""
- current_date: date = datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE))
- final_events = "
"
+ current_date: date = datetime.now(ZoneInfo(CALENDAR_TIMEZONE))
+ final_events: str = "
"
if not events:
- print("No upcoming events found.")
+ final_events += "
"
+ final_events += (
+ """ """
+ + " "
+ + """
"""
+ )
+ final_events += (
+ ""
+ + "No Events on the Calendar!"
+ + "
"
+ )
+ return {"data": final_events}
for event in events:
- formatted = (
- event.date.humanize() if event.date > current_date else "Happening Now!"
- )
- event.date = formatted
+ formatted: str = ""
+ if event.date < current_date:
+ formatted = (
+ f"Happening in {event.location}!"
+ if event.location
+ else "Happening Now!"
+ )
+ else:
+ formatted = event.date.humanize().title()
+
final_events += (
""" """
+ formatted
@@ -85,79 +117,136 @@ def format_events(events: list[CalendarInfo]) -> dict:
return {"data": final_events}
-def get_future_events_google_api() -> list[CalendarInfo]:
+async def rebuild_calendar() -> None:
"""
- Fetches the first ten events using the google api client.
- Requires an API key to be estbalished as a env variable
-
- Returns:
- list: A list of CalendarInfo objects3
+ Fetches and rebuilds the global calendar cache. This does NOT return a new cache, but changes the global calendar cache
"""
- # pylint: disable=no-member
- events_result = (
- calendar_service.events()
- .list(
- calendarId="rti648k5hv7j3ae3a3rum8potk@group.calendar.google.com",
- timeMin=datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE)).isoformat(),
- maxResults=10,
- singleEvents=True,
- orderBy="startTime",
+ global calendar_cache, cal_last_update, cal_currently_rebuilding
+
+ cal_currently_rebuilding = True
+
+ try:
+ found_events: set[CalendarInfo] = set()
+ response: httpx.Response = await cshcal_client.get(CALENDAR_URL, timeout=10)
+ response.raise_for_status()
+
+ cal: Calendar = Calendar.from_ical(response.content)
+
+ current_time: date = datetime.now(ZoneInfo(CALENDAR_TIMEZONE))
+
+ fetched_daily_events: list[Event] = recurring_ical_events.of(cal).between(
+ current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS)
)
- .execute()
- )
- events = events_result.get("items", [])
- formatted_events: list[CalendarInfo] = []
+ for event in fetched_daily_events:
+ new_event: CalendarInfo = CalendarInfo(
+ event.get("SUMMARY"),
+ event.get("DTSTART").dt,
+ event.get("LOCATION"),
+ )
+ found_events.add(new_event)
+ except Exception as e:
+ logger.warning("Failed to rebuild calendar cache! Error:")
+ logger.warning(e)
- for event in events:
- start = event["start"].get("dateTime") or event["start"].get("date")
- new_event = CalendarInfo(event["summary"], datetime.fromisoformat(start))
- formatted_events.append(new_event)
+ cal_last_update = current_time
+ calendar_cache = sorted(found_events, key=lambda x: x.date)[
+ :CALENDAR_EVENT_MAXIMUM
+ ] # Only cache the first elements of this list
+ cal_currently_rebuilding = False
+
+
+async def wait_for_rebuild() -> tuple[CalendarInfo]:
+ """
+ Simple yielding function for waiting to return the freshly made calendar cache, rather than proceeding with the obtain
+
+ Returns:
+ list: A list of CalendarInfo objects
+ """
+ global cal_currently_rebuilding
+ while cal_currently_rebuilding:
+ await asyncio.sleep(1)
- return formatted_events
+ return calendar_cache
-def get_future_events_ical() -> list[CalendarInfo]:
+async def get_future_events() -> tuple[CalendarInfo]:
"""
- Fetches the first ten events using the Ical library,
- loops through the first 7 days of the current time.
+ Returns the first events up to event maximum within the the calendar outlook day amount
+ custom object has name, date and the location
- Returns:
- list: A list of CalendarInfo objects3
+ Returns:
+ list: A list of CalendarInfo objects
"""
- found_events: list[CalendarInfo] = []
+ global \
+ calendar_cache, \
+ cal_last_update, \
+ header_last_modified, \
+ header_none_match, \
+ cal_currently_rebuilding
+
+ if cal_currently_rebuilding:
+ return await wait_for_rebuild()
+
+ cur_time: date = datetime.now(ZoneInfo(CALENDAR_TIMEZONE))
+ cal_correct_length: bool = len(calendar_cache) == CALENDAR_EVENT_MAXIMUM
+ if (
+ cal_last_update
+ and cal_correct_length
+ and (cal_last_update > (cur_time - timedelta(minutes=CALENDAR_CACHE_REFRESH)))
+ ):
+ logger.info("Pulling from CSH calendar cache!")
+ return calendar_cache
+
+ logger.info("Checking to rebuild CSH Calendar...")
try:
- response = requests.get(config.CALENDAR_URL, timeout=10)
- report_timing("Fetched the calendar from google")
+ headers: dict[str, str | None] = {}
- cal = Calendar.from_ical(response.content)
- report_timing("Converted the calendar info")
+ if header_none_match:
+ headers["If-None-Match"] = header_none_match
+ if header_last_modified:
+ headers["If-Modified-Since"] = header_last_modified
- current_day = 1
- current_time = datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE))
+ response: httpx.Response = await cshcal_client.get(
+ CALENDAR_URL, timeout=10, headers=headers
+ )
+ response.raise_for_status()
- while (current_day < config.CALENDAR_OUTLOOK_DAYS) and (
- len(found_events) < config.CALENDAR_EVENT_MAXIMUM
+ if ( # Nothing changed status code
+ response.status_code == 304 and cal_correct_length
):
- fetched_daily_events: list = recurring_ical_events.of(cal).between(
- current_time, current_time + timedelta(days=1)
- )
- report_timing("Sorted events on day " + str(current_day))
-
- for event in fetched_daily_events:
- if len(found_events) >= config.CALENDAR_EVENT_MAXIMUM:
- break
- else:
- new_event = CalendarInfo(
- event.get("SUMMARY"), event.get("DTSTART").dt
- )
- found_events.append(new_event)
-
- current_time += timedelta(days=1)
- current_day += 1
+ logger.info("CSH calendar not updated, refreshing last update!")
+ cal_last_update = cur_time
+ return calendar_cache
+
+ header_none_match = response.headers.get("ETag")
+ header_last_modified = response.headers.get("Last-Modified")
+
+ if cal_currently_rebuilding:
+ return await wait_for_rebuild()
+
+ if cal_correct_length:
+ logger.info("Calendar cache is full length, rebuilding async!")
+ asyncio.create_task(
+ rebuild_calendar()
+ ) # Calendar is correct length, we can just run this in the background
+ else:
+ logger.info("Calendar cache is NOT full length, yielding rebuild!")
+ await rebuild_calendar()
+ return calendar_cache
except Exception as e:
- logger.warning("Failed to fetch the Calendar! Failed with error:")
+ logger.warning("Failed to fetch the Calendar!")
logger.warning(e)
- sorted_events = sorted(found_events, key=lambda x: x.date)
- return sorted_events
+
+async def close_cal_client() -> None:
+ """
+ Closes the calendar's HTTPX client, logs if the event loops has been
+ closed prior to the function being called
+ """
+ global cshcal_client
+ try:
+ await cshcal_client.aclose()
+ except RuntimeError as e:
+ logger.warning("EVENT LOOP HAS ALREADY CLOSED, FAILED TO CLOSE csh_cal")
+ return
diff --git a/src/main.py b/src/main.py
index 420ded8..8fb77a1 100644
--- a/src/main.py
+++ b/src/main.py
@@ -14,15 +14,26 @@
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse, HTMLResponse
+from contextlib import asynccontextmanager
+
from config import BASE_DIR
from api import endpoints
+from core import cshcalendar
logger: Logger = getLogger(__name__)
-logger.info("Starting up the Jumpstart application!")
-app: FastAPI = FastAPI(docs_url="/swag")
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logger.info("Starting up the Jumpstart application!")
+ yield
+ logger.info("Shutting down the Jumpstart application!")
+ await cshcalendar.close_cal_client()
+ logger.info("Succesfully shut down the Jumpstart application!")
+
+
+app: FastAPI = FastAPI(docs_url="/swag", lifespan=lifespan)
logger.info("Mounting static files and templates!")
app.mount(
diff --git a/src/templates/index.html b/src/templates/index.html
index fbfe873..1f23266 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -29,8 +29,29 @@
+