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 @@
- ROCHESTER WEATHER + + ROCHESTER WEATHER
+