diff --git a/Dockerfile b/Dockerfile index a3476f8..bbe4bd0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,20 +3,12 @@ FROM python:3 ENV TZ=America/Chicago RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -# COPY . /outlook_calendar_report_generator -# WORKDIR /outlook_calendar_report_generator -# RUN python -m pip install -r /outlook_calendar_report_generator/requirements.txt -RUN apt-get update -y \ - && apt-get install ssmtp -y \ - && echo mailhub=smtp-server >> /etc/ssmtp/ssmtp.conf \ - && echo FromLineOverride=YES >> /etc/ssmtp/ssmtp.conf \ - && apt-get clean - -# RUN apt-get update -y -# RUN apt-get install ssmtp -y -# RUN echo mailhub=smtp-server >> /etc/ssmtp/ssmtp.conf -# RUN echo FromLineOverride=YES >> /etc/ssmtp/ssmtp.conf -# RUN apt-get clean +# RUN apt-get update -y \ +# && apt-get install ssmtp -y \ +# && echo mailhub=smtp-server >> /etc/ssmtp/ssmtp.conf \ +# && echo FromLineOverride=YES >> /etc/ssmtp/ssmtp.conf \ +# && apt-get clean + RUN mkdir /home/vacation_calendar_sync COPY . /vacation_calendar_sync @@ -34,4 +26,6 @@ CMD ["bash"] #CMD [ "python3 OutlookCalendar.py -s" ] +ENTRYPOINT [ "./entrypoint.sh" ] + diff --git a/OutlookCalendar.py b/OutlookCalendar.py index f7ff745..8767abe 100644 --- a/OutlookCalendar.py +++ b/OutlookCalendar.py @@ -1,239 +1,16 @@ #!/usr/bin/python -from http import client -import json -from azure.identity import DeviceCodeCredential -from msgraph.core import GraphClient -import yaml -from GenerateReport import GenerateReport import SharedCalendar import argparse from datetime import datetime -from dataclasses import dataclass from SimpleEvent import SimpleEvent -import os from datetime import timedelta import time import logging from logging import handlers import utils -import requests -import sys -import azure from msal import PublicClientApplication - - -EVENT_STATUS = 'oof' # out of office - -class OutlookCalendar: - def __init__(self, configs): - """ - Initializes the members variables by retrieving the netrc and yaml file - """ - - required_attributes = ['client_id', 'tenant_id', 'scopes', 'group_members', 'shared_calendar_name', 'logging_file_path', 'days_out', 'update_interval'] - - for attribute in required_attributes: - assert attribute in configs, f"{attribute} is not provided in microsoft_graph_auth.yaml" - setattr(self, attribute, configs[attribute]) - - #self.device_code_credential = DeviceCodeCredential(client_id = self.client_id, tenant_id = self.tenant_id) - #self.user_client = GraphClient(credential=self.device_code_credential, scopes=self.scope.split(' ')) - self.app = PublicClientApplication(client_id=self.client_id, authority=f"https://login.microsoftonline.com/{self.tenant_id}") - - def get_individual_calendars(self, start_date, end_date, access_token): - """ - Retrieves and returns a json object of individuals'calendar events - that are within/overlap between the start_date and end_date - - Args: - start_date (datetime object): the start date of the calendar (YYYY-MM-DD) - end_date (dateime object): the end date of the calendar (YYYY-MM-DD) - - Returns: - json: json object of the events within/overlap between the start and end date - """ - - #access_token = utils.acquire_access_token(self.app, self.scopes) - - header = { - #'Authorization': str(self.device_code_credential.get_token(self.scope)), # Retrieves the access token - 'Authorization': access_token, # Retrieves the access token - 'Content-Type': "application/json", - 'Prefer': "outlook.timezone=\"Central Standard Time\"" - } - - payload = { - "schedules": self.group_members, # List of the net_ids of each individual listed in the yaml file - "startTime": { - "dateTime": datetime.strftime(start_date, "%Y-%m-%dT%H:%M:%S"), - "timeZone": "Central Standard Time" - }, - "endTime": { - "dateTime": datetime.strftime(end_date, "%Y-%m-%dT%H:%M:%S"), - "timeZone": "Central Standard Time" - }, - "availabilityViewInterval": 1440 # Duration of an event represented in minutes - } - - # If succcesful, the response.json() includes the events that occur within the inverval of start_date and end_date - # This would include events that: - # start before start_date and end before end_date, - # start before start_date and end after end_date, - # start after start_date and end before end_date, - # start after start_date and end after end_date - # The exception is if the event start on the end_date. That event will not be included in the response.json() - - try: - endpoint = "https://graph.microsoft.com/v1.0/me/calendar/getSchedule" - #response = self.user_client.post('/me/calendar/getSchedule', data=json.dumps(payload), headers=header) - response = requests.post(endpoint, data=json.dumps(payload),headers= header) - except Exception as error: - logging.error(f"An error occured:\n{error}") - - with open("status.log", "w") as f: - f.write("An Exception has occured") - - - #utils.send_email(self.user_client, self.get_access_token(), error) - - #print(response.json()) - - #response = self.user_client.post('/me/calendar/getSchedule', data=json.dumps(payload), headers=header) - if (response.status_code == 200): - return response.json() - else: - #utils.send_email(self.user_client, utils.acquire_access_token(self.app, self.scopes), "Unable to retrieve individual calendars") - raise Exception(response.json()) - - def get_shared_calendar(self, start_date, end_date, access_token): - """ - Retrieves and returns a json object of the shared calendar events - that are within/overlap between the start_date and end_date - - Args: - user_client (Graph Client Object): msgraph.core._graph_client.GraphClient - start_date (str): The start date of the timeframe - end_date (str): The end date of the timeframe - - Returns: - json: json object of the events within/overlap between the start and end date - """ - - #access_token = self.device_code_credential.get_token(self.scope) - #access_token = utils.acquire_access_token(self.app, self.scopes) - - header = { - 'Authorization': str(access_token), - 'Content-Type': 'application/json' - } - endpoint = "https://graph.microsoft.com/v1.0/me/calendars" - response = requests.get(endpoint, headers=header) - #response = self.user_client.get('/me/calendars', headers=header) - - # Loop through all the calendars available to the user, and find the one indicated in the yaml file and retrieve its calendar ID - #print(response.json()) - for calendar in response.json()['value']: - if calendar['name'] == self.shared_calendar_name: - self.shared_calendar_id = calendar['id'] - break - - start_date = str(start_date.date()) - end_date = str(end_date.date()) - - header = { - 'Authorization': str(access_token), - 'Prefer': "outlook.timezone=\"Central Standard Time\"" - } - - # If succcesful, the response.json() includes the events that occur within the inverval of start_date and end_date - # Include events that: - # start between start_date and end_date (includes start_date) - # The exception is if the event start on the end_date. That event will not be included in the response.json() - - request = '/me/calendars/' + self.shared_calendar_id +'/events' + '?$select=subject,body,start,end,showAs&$top=100&$filter=start/dateTime ge ' + '\''+ start_date + '\'' + ' and start/dateTime lt ' + '\'' + end_date + '\'' - # response = self.user_client.get(request, headers=header) - - - endpoint = "https://graph.microsoft.com/v1.0" + request - response = requests.get(endpoint, headers=header) - #print("Shared calendar:") - #print(response.json()) - if (response.status_code == 200): - return response.json() - else: - #utils.send_email(self.user_client, access_token, "Unable to retrieve shared calendar") - raise Exception(response.json()) - - - def process_individual_calendars(self, calendar, user_start_date, user_end_date): - """ - Creates simple event objects using the the individual work calendars - - Args: - calendar (json): json object of the events within/overlap between a specified start and end date for indvidual calendars - user_start_date (datetime): a datetime object that represents the start time specified by the user (the current date) - - Returns: - list: A list of SimpleEvent objects - - """ - #print(calendar) - filtered_events = [] - for member in calendar['value']: - net_id = member['scheduleId'].split('@')[0] - try: - for event in member['scheduleItems']: - if event['status'] != EVENT_STATUS: continue - - simple_events = SimpleEvent.create_event_for_individual_calendars(event, user_start_date, user_end_date, net_id) - - filtered_events.extend(simple_events) - except KeyError as e: - logger.warning(f"Unable to find: " + net_id) - - return filtered_events - - def process_shared_calendar(self, shared_calendar): - """ - Creates simple event objects using the the individual work calendars - - Args: - calendar (json): json object of the events within/overlap between a specified start and end date for indvidual calendars - user_start_date (datetime): a datetime object that represents the start time specified by the user (the current date) - - Returns: - tuple: A tuple containing a list of SimpleEvent objects and a list of the correspending event ids - """ - - filtered_events = [] - event_ids = {} - # the events can be multiday - - for event in shared_calendar['value']: - - if event['showAs'] != 'free': continue - - simple_event = SimpleEvent.create_event_for_shared_calendar(event, self.group_members) - # Only valid events are returned as a simpleEvent object - if simple_event == None: continue - - filtered_events.append(simple_event) - event_date = str(simple_event.date.date()) - - event_ids[simple_event.subject + event_date] = event['id'] - - return (filtered_events, event_ids) - - # @TODO: get rid of this function and call self.device_code_credential.get_token(self.scope) straight up instead - def get_access_token(self): - try: - access_token = self.device_code_credential.get_token(self.scope) - except azure.core.exceptions.ClientAuthenticationError as error: - logger.error("Need to authenticate with Microsoft Graph") - logger.error("An Exception has occured") - sys.exit(1) - - return access_token +import IndividualCalendar +#from GenerateReport import GenerateReport def process_args(): parser = argparse.ArgumentParser( @@ -257,7 +34,9 @@ def process_args(): return args def sanitize_input(start_date, end_date): - """ Sanitizes the user arguments to verify their validity """ + """ + Sanitizes the user arguments to verify their validity + """ # If the start_date and end_date given by user doesn't fit the format, then the datetime.strptime will # throw its own error @@ -269,72 +48,98 @@ def sanitize_input(start_date, end_date): return (start_date, end_date) def debug(configs): + #def __init__(self, client_id, tenant_id, scopes, group_members, shared_calendar_name, days_out, update_interval): print("In debug mode") - calendar = OutlookCalendar(configs) - days_out = timedelta(days=7) - start_date = datetime(year=2023, month=8, day=21) + #calendar = OutlookCalendar(configs['client_id'], configs['tenant_id'], configs['scopes'], configs['group_name'], configs['shared_calendar_name'], configs['days_out'], configs['update_interval']) + days_out = timedelta(days=14) + start_date = datetime(year=2023, month=7, day=24) end_date = start_date + days_out + # Need the config + + # # Retrieve the group member emails + # last_updated_list, group_members = utils.get_email_list(configs['group_name'], configs['email_list_update_interval']) - access_token = utils.acquire_access_token(calendar.app, calendar.scopes) + # # Define the msal public client + app = PublicClientApplication(client_id=configs['client_id'], authority=f"https://login.microsoftonline.com/{configs['tenant_id']}") - individual_calendars = calendar.process_individual_calendars(calendar.get_individual_calendars(start_date, end_date, access_token), start_date, end_date) - shared_calendar_events, event_ids = calendar.process_shared_calendar(calendar.get_shared_calendar(start_date, end_date, access_token)) - SharedCalendar.update_shared_calendar(individual_calendars, shared_calendar_events, event_ids, calendar.shared_calendar_id, access_token) - #utils.get_groups_belonging_to_user(access_token) - - #utils.send_email(calendar.user_client, calendar.get_access_token(), "test") + # # Get access token + access_token = utils.acquire_access_token(app, configs['scopes']) + # #print(f"access_token: {access_token}") + + # # Retrieve the individual calendar and process it + # individual_calendars = IndividualCalendar.get_individual_calendars(start_date, end_date, group_members, access_token) + # individual_calendars_events = IndividualCalendar.process_individual_calendars(individual_calendars, start_date, end_date) + + # # Retrieve the shared calendar and process it + # shared_calendar_id = SharedCalendar.get_shared_calendar_id(configs['shared_calendar_name'], access_token) + # shared_calendar = SharedCalendar.get_shared_calendar(shared_calendar_id, start_date, end_date, access_token) + # shared_calendar_events, event_ids = SharedCalendar.process_shared_calendar(shared_calendar, group_members) + + # # Update the shared calendar + # SharedCalendar.update_shared_calendar(individual_calendars_events, shared_calendar_events, event_ids, shared_calendar_id, + # configs['category_name'], configs['category_color'], access_token) def main(configs): - calendar = OutlookCalendar(configs) args = process_args() start_date = None end_date = None - days_out = timedelta(days=calendar.days_out) + days_out = timedelta(days=configs['days_out']) + last_updated_list = None + group_members = None - if args.update_shared_calendar: - count = 0 - while True: - access_token = utils.acquire_access_token(calendar.app, calendar.scopes) - logger.info("Updating shared calendar -> Count : {count}".format(count=count)) - + # Define the msal public client + app = PublicClientApplication(client_id=configs['client_id'], authority=f"https://login.microsoftonline.com/{configs['tenant_id']}") + + count = 0 + while True: + logger.info("Updating shared calendar -> Count : {count}".format(count=count)) + + if args.update_shared_calendar: today = datetime.today() start_date = datetime(year=today.year, month=today.month, day=today.day, hour=0,minute=0) end_date = start_date + days_out + elif args.manual_update: + dates = sanitize_input(args.manual_update[0], args.manual_update[1]) + start_date = dates[0] + end_date = dates[1] - individual_calendar_events = calendar.process_individual_calendars(calendar.get_individual_calendars(start_date, end_date, access_token), start_date, end_date) - shared_calendar_events, event_ids = calendar.process_shared_calendar(calendar.get_shared_calendar(start_date, end_date, access_token)) - - SharedCalendar.update_shared_calendar(individual_calendar_events, shared_calendar_events, event_ids, calendar.shared_calendar_id, access_token) + # Retrieve the group member emails + last_updated_list, group_members = utils.get_email_list(configs['group_name'], configs['email_list_update_interval'], group_members, last_updated_list) - count = count + 1 - time.sleep(calendar.update_interval) - - if args.dump_json: - shared_calendar_events, event_ids = calendar.process_shared_calendar(calendar.get_shared_calendar(start_date, end_date, access_token)) - GenerateReport(shared_calendar_events, None).dump_calendar_to_json(shared_calendar_events, start_date, end_date) + # Get access token + access_token = utils.acquire_access_token(app, configs['scopes']) + + # Retrieve the individual calendar and process it + individual_calendars = IndividualCalendar.get_individual_calendars(start_date, end_date, group_members, access_token) + individual_calendars_events = IndividualCalendar.process_individual_calendars(individual_calendars, start_date, end_date) + + # Retrieve the shared calendar and process it + shared_calendar_id = SharedCalendar.get_shared_calendar_id(configs['shared_calendar_name'], access_token) + shared_calendar = SharedCalendar.get_shared_calendar(shared_calendar_id, start_date, end_date, access_token) + shared_calendar_events, event_ids = SharedCalendar.process_shared_calendar(shared_calendar, group_members) + + # Update the shared calendar + SharedCalendar.update_shared_calendar(individual_calendars_events, shared_calendar_events, event_ids, shared_calendar_id, configs['category_name'], configs['category_color'], access_token) - if args.manual_update: - access_token = utils.acquire_access_token(calendar.app, calendar.scopes) - dates = sanitize_input(args.manual_update[0], args.manual_update[1]) - start_date = dates[0] - end_date = dates[1] - individual_calendar_events = calendar.process_individual_calendars(calendar.get_individual_calendars(start_date, end_date, access_token), start_date, end_date) - shared_calendar_events, event_ids = calendar.process_shared_calendar(calendar.get_shared_calendar(start_date, end_date, access_token)) - SharedCalendar.update_shared_calendar(individual_calendar_events, shared_calendar_events, event_ids, calendar.shared_calendar_id, access_token) + if args.manual_update: break + + count = count + 1 + time.sleep(configs['update_interval']) + if __name__ == '__main__': - configs = utils.retrieve_from_yaml() + configs = utils.get_configurations() formater = logging.Formatter('%(name)s:%(asctime)s:%(filename)s:%(levelname)s:%(message)s') - rotate_file_handler = handlers.RotatingFileHandler(configs['logging_file_path'], mode='a', maxBytes=2000000, backupCount=2) - #rotate_file_handler = handlers.RotatingFileHandler("output_event.log", maxBytes=2048, backupCount=2) - rotate_file_handler.setFormatter(fmt=formater) - rotate_file_handler.setLevel(logging.DEBUG) + + rotate_file_handler_info = handlers.RotatingFileHandler(f"{configs['logging_file_path']}/vcs.log", mode='a', maxBytes=2000000, backupCount=2) + rotate_file_handler_info .setFormatter(fmt=formater) + rotate_file_handler_info .setLevel(logging.INFO) logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) - logger.addHandler(rotate_file_handler) + logger.addHandler(rotate_file_handler_info) stream_handler = logging.StreamHandler() stream_handler.setLevel(logging.DEBUG) diff --git a/SharedCalendar.py b/SharedCalendar.py index 0d84b99..185f4bb 100644 --- a/SharedCalendar.py +++ b/SharedCalendar.py @@ -6,22 +6,129 @@ import math import utils import requests +from SimpleEvent import SimpleEvent MAX_REQUESTS_PER_BATCH = 20 # This logger is a child of the __main__ logger located in OutlookCalendar.py logger = logging.getLogger("__main__." + __name__) + +def get_shared_calendar_id(shared_calendar_name, access_token): + """ + Retrieves the calendar of the specified calendar + + Args: + shared_calendar_name (str): the name of the user specified calendar + access_token (str): the token used make calls to the Microsoft Graph API + as part of the Oauth2 Authorization code flow + + Returns: + str: the id of the user specified calendar + """ + + header = { + 'Authorization': str(access_token), + 'Content-Type': 'application/json' + } + endpoint = "https://graph.microsoft.com/v1.0/me/calendars" + response = requests.get(endpoint, headers=header) + + if response.status_code != 200: + logger.error(f"Unable to connect to the {endpoint} endpoint to retrieve {shared_calendar_name}") + raise Exception(response.json()) + + # Loop through all the calendars available to the user, and find the one indicated in the yaml file and retrieve its calendar ID + for calendar in response.json()['value']: + if calendar['name'] == shared_calendar_name: + return calendar['id'] + + logger.error(f"{shared_calendar_name} was not found") + raise Exception(response.json()) + +def get_shared_calendar(shared_calendar_id, start_date, end_date, access_token): + """ + Retrieves a json object of the shared calendar events \ + between the start_date to end_date (including start_date and excluding end_date) + + Args: + start_date (datetime): the start date of timeframe being updated + end_date (dateime): the end date of timeframe being updated + access_token (str): the token used make calls to the Microsoft Graph API \ + as part of the Oauth2 Authorization code flow + + Returns: + json: json object of the events between the start and end date + (including start_date and excluding end_date) + """ + + start_date = str(start_date.date()) + end_date = str(end_date.date()) + + header = { + 'Authorization': str(access_token), + 'Prefer': "outlook.timezone=\"Central Standard Time\"" + } + + # If succcesful, the response.json() includes the events that occur within the inverval of start_date and end_date + # Include events that: + # start between start_date and end_date (includes start_date) + # The exception is if the event start on the end_date. That event will not be included in the response.json() + + endpoint = 'https://graph.microsoft.com/v1.0/me/calendars/' + shared_calendar_id +'/events?$select=subject,body,start,end,showAs&$top=100&$filter=start/dateTime ge ' + '\''+ start_date + '\'' + ' and start/dateTime lt ' + '\'' + end_date + '\'' + response = requests.get(endpoint, headers=header) + + if (response.status_code != 200): + message = f'Unable to retrieve shared calendar from {endpoint} endpoint' + logging.error(message) + utils.send_email(f'Unable to retrieve shared calendar from {endpoint} endpoint') + raise Exception(response.json()) + + return response.json() + +def process_shared_calendar(shared_calendar, group_members): + """ + Creates simple event objects using the the individual work calendars + + Args: + shared_calendar (json): json object of the events within/overlap between a specified start and end date for indvidual calendars + group_members (list): A list of emails of the group members + + Returns: + tuple: A tuple containing a list of SimpleEvent objects and a list of the correspending event ids + """ + + filtered_events = [] + event_ids = {} + # the events can be multiday -def update_shared_calendar(individual_calendars, shared_calendar, event_ids, shared_calendar_id, access_token): + for event in shared_calendar['value']: + + if event['showAs'] != 'free': continue + + simple_event = SimpleEvent.create_event_for_shared_calendar(event, group_members) + # Only valid events are returned as a simpleEvent object + if simple_event == None: continue + + filtered_events.append(simple_event) + event_date = str(simple_event.date.date()) + + event_ids[simple_event.subject + event_date] = event['id'] + + return (filtered_events, event_ids) + +def update_shared_calendar(individual_calendars, shared_calendar, event_ids, shared_calendar_id, category_name, category_color, access_token): """ Update the specified shared calendar by adding and deleting events from it Args: - individual_calendars (list): A list of SimpleEvents from each member's calendars - shared_calendar: A list of SimpleEvents obtained from the shared calendar - shared_calendar_id (str): The associated id to the shared calendar - access_token (str): The access token for the project - user_client (GraphClient Object) + individual_calendars (list): a list of SimpleEvents from each member's calendars + shared_calendar (list): a list of SimpleEvents obtained from the shared calendar + event_ids (dict): a dictionary containing the ids of the events on the shared calendar + shared_calendar_id (str): the associated id to the shared calendar + category_name: the name of the category for the event + category_color: the color of the category for the event + access_token (str): the token used make calls to the Microsoft Graph API \ + as part of the Oauth2 Authorization code flow """ individual_events = set(create_tuple(individual_calendars)) @@ -30,7 +137,7 @@ def update_shared_calendar(individual_calendars, shared_calendar, event_ids, sha events_to_add = individual_events.difference(shared_events) events_to_delete = shared_events.difference(individual_events) - batches = create_batches_for_adding_events(events_to_add, access_token, shared_calendar_id) + batches = create_batches_for_adding_events(events_to_add, access_token, shared_calendar_id, category_name, category_color) post_batch(access_token, batches) batches, deleted_event_info = create_batches_for_deleting_events(events_to_delete, access_token, shared_calendar_id, event_ids) @@ -106,10 +213,9 @@ def create_batches_for_deleting_events(events, access_token, calendar_id, event_ event_info = {} deleted_events_info.append(event_info) - #print(deleted_events_info) return (batches, deleted_events_info) -def create_batches_for_adding_events(events, access_token, calendar_id): +def create_batches_for_adding_events(events, access_token, calendar_id, category_name, category_color): """ Create the batches for events being added to the shared_calendar using the format indicated by the Microsoft Graph API for batch @@ -117,12 +223,15 @@ def create_batches_for_adding_events(events, access_token, calendar_id): events (list): a list of tuples (net_id, subject, date). date has format of YYYY-MM-DD access_token: a token to use the services offered by the Microsoft Graph API calendar_id (str): the id of the specified shared calendar + category_name: the name of the category for the event + category_color: the color of the category for the event Returns: A list of dictionaries (batches) """ # A list of dictionaries + category = get_category(access_token, category_name, category_color) batches = [] num_of_batches = math.ceil(len(events) / MAX_REQUESTS_PER_BATCH) @@ -155,7 +264,7 @@ def create_batches_for_adding_events(events, access_token, calendar_id): "dateTime": end_date_time, "timeZone": "Central Standard Time" }, - "categories": ["vacation"] + "categories": [category] }, "headers": { "Authorization": access_token, @@ -172,6 +281,13 @@ def create_batches_for_adding_events(events, access_token, calendar_id): return batches def check_add_response(batch, batch_responses, access_token): + """ + Checks each of the add event calls from the batch + + Args: + batch (dict): The request body to the Microsoft Graph batch endpoint + batch_responses (dict): The response from the batch request + """ message = "" for response in batch_responses: @@ -181,22 +297,30 @@ def check_add_response(batch, batch_responses, access_token): id = int(response['id']) subject = batch['requests'][id - 1]['body']['subject'] date = batch['requests'][id - 1]['body']['start']['dateTime'] - logger.error(f"Event {subject} on {date} was unccessfully added") - logger.error("Error: {response['body']['error']}") + logger.warning(f"Event {subject} on {date} was unccessfully added") + logger.warning(f"Error: {response['body']['error']}") message = message + f"Event {subject} on {date} was unccessfully added\n" # if (len(message) != 0): # utils.send_email(user_client, access_token, message) def check_deleted_response(batch, batch_responses, access_token, info): + """ + Checks each of the delete event calls from the batch + + Args: + batch_responses (dict): The response from the batch request + info (dict): a dictionary containing the events set to be deleted + """ + for response in batch_responses: id = response["id"] event = info[id] if response["status"] == 204: logger.info(f"Event {event[1]} on {event[2]} was succesfully deleted") else: - logger.info(f"Event {event[1]} on {event[2]} was unsuccesfully deleted") - logger.error(f"Error: {response['body']['error']}") + logger.warning(f"Event {event[1]} on {event[2]} was unsuccesfully deleted") + logger.warning(f"Error: {response['body']['error']}") def post_batch(access_token, batches, info=None): @@ -218,11 +342,11 @@ def post_batch(access_token, batches, info=None): for count, batch in enumerate(batches): response = requests.post(endpoint, data=json.dumps(batch), headers=header) #print(batch) - if "error" in response: + if response.status_code != 200: message = "Unable to post batch \n" + str(response.json()["error"]) #utils.send_email(user_client, access_token, message) - logger.error(response.json()["error"]) - #counter = counter + 1 + logger.warning(message) + logger.warning(response.json()) continue if info: @@ -230,3 +354,70 @@ def post_batch(access_token, batches, info=None): else: check_add_response(batch, response.json()["responses"], access_token) +def get_category(access_token, category_name, category_color): + """ + Retrieves the user category master list, and finds the category_name in it. If not, the specified category will be created + + Args: + access_token: a token to use the services offered by the Microsoft Graph API + category_name: the name of the user specified category + category_color: the color for the category if the category doesn't exist + + Returns: + str: the name of the user specified category + + """ + + endpoint = 'https://graph.microsoft.com/v1.0/me/outlook/masterCategories' + headers = { + 'Authorization': access_token + } + + response = requests.get(endpoint, headers=headers) + if (response.status_code != 200): + logger.error(f"Unable to find {category_name} category") + raise Exception(response.json()) + + response = response.json()['value'] + + for category in response: + if category['displayName'] == category_name: + return category_name + + return create_category(access_token, category_name, category_color) + + +def create_category(access_token, category_name, category_color): + """ + Create the user specified category + + Args: + access_token: a token to use the services offered by the Microsoft Graph API + category_name: the name of the user specified category + category_color: the color for the category if the category doesn't exist + + Returns: + str: the name of the user specified category + """ + + endpoint = 'https://graph.microsoft.com/v1.0/me/outlook/masterCategories' + headers = { + 'Authorization': access_token, + 'Content-Type': 'application/json' + } + # Can find the list of preset colors at https://learn.microsoft.com/en-us/graph/api/resources/outlookcategory?view=graph-rest-1.0 + body = { + 'displayName': category_name, + 'color': category_color + } + + response = requests.post(endpoint, data=json.dumps(body), headers=headers) + + if response.status_code != 201: + logger.error(f"Unable to create {category_name}") + logger.error(response.json()) + raise Exception() + #print("category created") + return category_name + + diff --git a/SimpleEvent.py b/SimpleEvent.py index 34a0eee..1dbf3f4 100644 --- a/SimpleEvent.py +++ b/SimpleEvent.py @@ -3,12 +3,12 @@ from datetime import timedelta import yaml import os +import utils -path = os.getenv('VCS_CONFIG') -with open(path, 'r') as file: - dictionary = yaml.safe_load(file) - AM_config = dictionary['AM_config'] - PM_config = dictionary['PM_config'] + +configs = utils.get_configurations() +AM_config = configs['AM_config'] +PM_config = configs['PM_config'] @dataclass class SimpleEvent: @@ -20,13 +20,13 @@ class SimpleEvent: # The list will return 1 item if the event is a one day event # Otherwise, the length of the list is equal to length of the event in terms of days @classmethod - def create_event_for_individual_calendars(cls, event, user_start_date, user_end_date, net_id): + def create_event_for_individual_calendars(cls, event, start_date, end_date, net_id): ''' Create SimpleEvents and returns a list of SimpleEvents using events from individual calendars Args: event (dict): contains the information about the event - user_start_date (datetime): the start date given by the user (today's date) + start_date (datetime): the start date given by the user (today's date) net_id (str): the netid of owner of the event Returns: @@ -38,7 +38,7 @@ def create_event_for_individual_calendars(cls, event, user_start_date, user_end_ end = SimpleEvent.make_datetime(event['end']['dateTime']) if start.date() == end.date(): - if SimpleEvent.is_event_valid(user_start_date, user_end_date, start, end): + if SimpleEvent.is_event_valid(start_date, end_date, start, end): return [cls(net_id, SimpleEvent.get_event_subject(start, end, net_id), start)] return [] @@ -63,7 +63,7 @@ def create_event_for_individual_calendars(cls, event, user_start_date, user_end_ new_start = new_start.replace(hour=0,minute=0,second=0) new_end = new_end.replace(hour=23,minute=59,second=59) - if SimpleEvent.is_event_valid(user_start_date, user_end_date, new_start, new_end): + if SimpleEvent.is_event_valid(start_date, end_date, new_start, new_end): events.append(cls(net_id, SimpleEvent.get_event_subject(new_start, new_end, net_id), new_start)) return events diff --git a/requirements.txt b/requirements.txt index ec59c11..9edb799 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,23 @@ #exchangelib -grab == 0.6.40 +#grab == 0.6.40 tzlocal PyYAML azure-core==1.23.1 azure-identity==1.10.0 -certifi==2021.10.8 -cffi==1.15.0 +#certifi==2021.10.8 +#cffi==1.15.0 charset-normalizer==2.0.12 -cryptography==37.0.1 +#cryptography==37.0.1 idna==3.3 msal==1.17.0 msal-extensions==1.0.0 msgraph-core==0.2.2 -portalocker==2.4.0 +#portalocker==2.4.0 pycparser==2.21 -PyJWT==2.3.0 +#PyJWT==2.3.0 requests==2.27.1 six==1.16.0 -typing_extensions==4.2.0 +#typing_extensions==4.2.0 urllib3==1.26.9 -tabulate==0.9.0 +#tabulate==0.9.0 +ldap3 diff --git a/utils.py b/utils.py index be5988e..bc45d68 100644 --- a/utils.py +++ b/utils.py @@ -1,14 +1,27 @@ import json import yaml import os -import subprocess -from msal import PublicClientApplication import os.path import requests +import ldap3 +from datetime import datetime +import logging SUBJECT = "Vacation Calendar Sync Error Notification" +logger = logging.getLogger("__main__." + __name__) def init_device_code_flow(app, scopes): + """ + Start of the Microsoft init device flow process + + Args: + app (PublicClientApplication object): the public client for the msal library + scope (list): a list consisting of the Azure permissions + + Returns: + json: json object of the events within/overlap between the start and end date + """ + flow = app.initiate_device_flow(scopes=scopes) print(flow["message"]) result = app.acquire_token_by_device_flow(flow) @@ -16,18 +29,29 @@ def init_device_code_flow(app, scopes): def acquire_access_token(app, scopes): + """ + Acquire the access token using the MSAL library + + Args: + app (PublicClientApplication object): the public client for the msal library + scope (list): a list consisting of the Azure permissions + + Returns: + str: the access token for the Microsoft Graph API + """ + collection_path = os.getenv('VCS_COLLECTION_PATH') # Note access_token usually lasts for a little bit over an hour result = None accounts = app.get_accounts() if accounts: # If accounts exist, that means that it is an iteration, since system rebooting and first time running won't have acccount - print("Tokens found in cache") + logger.debug("Tokens found in cache") result = app.acquire_token_silent(scopes, accounts[0]) elif os.path.isfile(collection_path + '/token.txt'): # if the token file exist, then read the refresh token and use it to acquire the access_token with open(collection_path + "/token.txt", "r") as file: - print("Refresh token found") + logger.debug("Refresh token found") refresh_token = file.readline() result = app.acquire_token_by_refresh_token(refresh_token, scopes) @@ -38,48 +62,37 @@ def acquire_access_token(app, scopes): if "access_token" in result: with open(collection_path + "/token.txt", "w") as file: file.write(result["refresh_token"]) - print("Writing new refresh token into token") + logger.debug("Writing new refresh token into token") return result["access_token"] else: - print(result.get("error")) - print(result.get("error_description")) - print(result.get("correlation_id")) - - -path = os.getenv('VCS_CONFIG') -with open(path, 'r') as file: - dictionary = yaml.safe_load(file) - recipient_email = dictionary['recipient_email'] - -def retrieve_from_yaml(): - required_attributes = ['client_id', 'tenant_id', 'scope', 'group_members', 'shared_calendar_name', 'logging_file_path', 'days_out', 'update_interval'] - - # Created ENV variable using docker's ENV command in Dockerfile - path = os.getenv('VCS_CONFIG') - with open(path, 'r') as file: - return yaml.safe_load(file) - # for attribute in required_attributes: - # assert attribute in dictionary, f"{attribute} is not provided in microsoft_graph_auth.yaml" - # setattr(self, attribute, dictionary[attribute]) - -def send_mail_using_host(message): - with open("email.txt", 'w') as f: - email = [f"To: {recipient_email}\n", f"Subject: {SUBJECT}\n", f"{message}\n"] - f.writelines(email) - - subprocess.run(f"sendmail {recipient_email} < email.txt", shell=True) + logger.error(result.get("error")) + logger.error(result.get("error_description")) + logger.error(result.get("correlation_id")) - -def send_email(user_client, access_token, message): +def get_configurations(): + """ + Retrieves the configurations from the vacation_calendar_sync_config file located at VCS_CONFIG - toRecipients = [] + Returns: + dict: the configs as a dict - recipient = { - "emailAddress": { - "address": recipient_email - } - } - toRecipients.append(recipient) + """ + # Created ENV variable using docker's ENV command in Dockerfile + path = os.getenv('VCS_CONFIG') + with open(path, 'r') as file: + return yaml.safe_load(file) + +def send_email(message, access_token): + config = get_configurations() + recipient_email = config['recipient_email'] + + """ + Sends an email to a list of recipients + + Args: + message (str): the message that is contained in the email + access_token (str): the token used make calls to the Microsoft Graph API as part of the Oauth2 Authorization code flow + """ endpoint = "https://graph.microsoft.com/v1.0/me/sendMail" @@ -95,32 +108,96 @@ def send_email(user_client, access_token, message): "contentType": "Text", "content": message }, - "toRecipients": toRecipients, - "ccRecipients": [ + # "toRecipients": [recipient] + "toRecipients": [ { "emailAddress": { - "address": toRecipients + "address": recipient_email } } ] }, "saveToSentItems": "false" } - response = user_client.post(endpoint, data=json.dumps(payload), headers=header) + response = requests.post(endpoint, data=json.dumps(payload), headers=header) + if (response.status_code != 202): + logger.error(response.json()) + raise Exception() + # TODO: consider a case if the status code isn't 202 + +def get_email_list(group_name, update_interval, current_email_list = None, last_updated = None): + """ + Retrieves the email list of the members in group_name - -def get_groups_belonging_to_user(access_token): - endpoint = "https://graph.microsoft.com/v1.0/me/memberOf" - endpoint_three = "https://graph.microsoft.com/v1.0/groups?$filter=displayName eq 'NCSA-Org-ICI'" - header = { - "Authorization": str(access_token), - } + Args: + group_name (str): The name of the specified group + current_email_list (list): The previously held list of emails + last_updated (datetime object): The last time the current_email_list was updated + + Returns: + A list of emails from the specified group_name + """ + + if not last_updated or divmod((last_updated - datetime.today()).total_seconds(), 60)[0] >= update_interval: + last_updated = datetime.today() + current_email_list = get_email_list_from_ldap(group_name) + return (last_updated, current_email_list) - endpoint_two = "https://graph.microsoft.com/v1.0/groups?$select=displayName" - response = requests.get(endpoint_two, headers=header) - print(response.json()) +def get_email_list_from_ldap(group_name): + """ + Retrieves the email list of the members in group_name using ldap server + + Args: + group_name (str): The name of the specified group + + Returns: + A list of emails from the specified group_name using ldap server + """ + ldap_server = "ldaps://ldap1.ncsa.illinois.edu" # Replace with your LDAP server + + ldap_user = None + ldap_password = None + search_base = 'dc=ncsa,dc=illinois,dc=edu' + + search_scope = ldap3.SUBTREE + attributes = ldap3.ALL_ATTRIBUTES + + group_list = [ + group_name + ] + + with ldap3.Connection(ldap_server, ldap_user, ldap_password) as conn: + if not conn.bind(): + logger.error("Error: Could not bind to LDAP server") + else: + for group_name in group_list: + search_filter = f"(cn={group_name})" + #print("search_filter: " + search_filter) + result = conn.search(search_base, search_filter, search_scope, attributes=attributes) + if not result: + logger.error(f"Error: Could not find group {group_name}") + else: + members = [ m.split(',')[0].split('=')[1] for m in conn.entries[0].uniqueMember ] + + emails = [] + for member in members: + result = conn.search(search_base, f"(uid={member})", search_scope, attributes=attributes) + if not result: + logger.error(f"Error: Could not find member with uid {member}") + else: + emails.append(str(conn.entries[0].mail)) + + temp_emails = [] + logger.debug(f"{len(emails)} emails were found") + for email in emails: + if '@illinois.edu' in email: + temp_emails.append(email) + else: + logger.warning(f"{email} is not a illinois affiliated email") + return temp_emails +