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: 1 addition & 1 deletion .env.template
Original file line number Diff line number Diff line change
@@ -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=
5 changes: 3 additions & 2 deletions src/api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import httpx
import random
import textwrap
import asyncio

from fastapi import APIRouter, Request, Form
from fastapi.responses import JSONResponse
Expand All @@ -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)

Expand Down
5 changes: 1 addition & 4 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
277 changes: 183 additions & 94 deletions src/core/cshcalendar.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = "<br>"
current_date: date = datetime.now(ZoneInfo(CALENDAR_TIMEZONE))
final_events: str = "<br>"

if not events:
print("No upcoming events found.")
final_events += "<hr style='border: 1px #B0197E solid;'>"
final_events += (
"""<div class='calendar-event-container-lvl2'><span class='calendar-text-date'> """
+ " "
+ """ </span><br>"""
)
final_events += (
"<span class='calendar-text' id='calendar'>"
+ "No Events on the Calendar!"
+ "</span></div>"
)
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 += (
"""<div class='calendar-event-container-lvl2'><span class='calendar-text-date'> """
+ formatted
Expand All @@ -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
Loading
Loading