You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

341 lines
14KB

  1. #!/usr/bin/env python3
  2. # Copyright (C) 2019 Campaign Against Arms Trade
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU General Public License for more details.
  13. # You should have received a copy of the GNU General Public License
  14. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  15. import os
  16. import csv
  17. import json
  18. import yaml
  19. import logging
  20. import argparse
  21. import configparser
  22. from pathlib import Path
  23. from os.path import abspath, dirname, realpath, isfile
  24. from requests import HTTPError
  25. from datetime import datetime, timedelta
  26. from O365 import (Account, Connection, FileSystemTokenBackend,
  27. MSOffice365Protocol)
  28. parser = argparse.ArgumentParser(prog='supper', description="Script to generate a seating plan via Office365 Calendars")
  29. parser.add_argument("-c", "--config", type=str, dest="config_path", default="{}/.config/supper.yaml".format(Path.home()),
  30. help="Path to a config file to read settings from. Default: ~/.config/supper.yaml")
  31. parser.add_argument("-o", "--output", type=str, dest="output_path", default="Seating Plan.csv",
  32. help="Path to save the output csv file to")
  33. parser.add_argument("-d", "--debug", action="store_true", help="Enable debug output")
  34. strftime_pattern = "%Y-%m-%dT%H:%M:%S"
  35. strptime_pattern = "%Y-%m-%dT%H:%M:%S.%f"
  36. access_token = f"{dirname(realpath(__file__))}/access_token"
  37. logger = logging.getLogger('supper')
  38. consoleHandler = logging.StreamHandler()
  39. logger.addHandler(consoleHandler)
  40. formatter = logging.Formatter('%(levelname)s: %(asctime)s - %(message)s')
  41. consoleHandler.setFormatter(formatter)
  42. def read_config_file(config_path):
  43. """
  44. Reads config file and sets up variables foo
  45. :return: config as a dict
  46. """
  47. with open(config_path, "r") as fp:
  48. config = yaml.load(fp, Loader=yaml.FullLoader)
  49. return config
  50. def format_output_path(output_path):
  51. """
  52. Checks the string for datetime formatting and formats it if possible.
  53. :param output_path: str of the output path
  54. :return: str of the new output path
  55. """
  56. try:
  57. new_path = output_path.format(datetime.now())
  58. if new_path.split(".")[-1] != "csv":
  59. new_path += ".csv"
  60. logger.info("Output path does NOT have '.csv' file extension. Adding '.csv' to end of output_path.")
  61. logger.debug("Formatted output file successfully as '{}'".format(new_path))
  62. return new_path
  63. except (SyntaxError, KeyError):
  64. # This is raised when formatting is incorrectly used in naming the output
  65. logger.error("Invalid formatting pattern given for output_path. Cannot name output_path. Exiting...")
  66. exit(1)
  67. def parse_args():
  68. """
  69. Parses arguments from the commandline.
  70. :return: config yaml file as a dict
  71. """
  72. args = parser.parse_args()
  73. if args.debug:
  74. logger.setLevel(logging.DEBUG)
  75. consoleHandler.setLevel(logging.DEBUG)
  76. else:
  77. logger.setLevel(logging.WARNING)
  78. consoleHandler.setLevel(logging.WARNING)
  79. output_path = format_output_path(args.output_path)
  80. if args.config_path:
  81. # Read the file provided and return the required config
  82. try:
  83. config = read_config_file(args.config_path)
  84. config["config_path"] = args.config_path
  85. config["output_path"] = output_path
  86. config["users"] = sorted([x.lower() for x in config["users"]]) # make all names lowercase and sort alphabetically
  87. logger.debug("Loaded config successfully from '{}'".format(args.config_path))
  88. return config
  89. except FileNotFoundError:
  90. # Cannot open file
  91. logger.error("Cannot find config file provided ({}). Maybe you mistyped it? Exiting...".format(args.config_path))
  92. exit(1)
  93. except (yaml.parser.ParserError, TypeError):
  94. # Cannot parse opened file
  95. # TypeError is sometimes raised if the metadata of the file is correct but the content doesn't parse
  96. logger.error("Cannot parse config file. Make sure the provided config is a YAML file and that is is formatted correctly. Exiting...")
  97. exit(1)
  98. else:
  99. # No provided -c argument
  100. logger.error("Cannot login. No config file was provided. Exiting...")
  101. exit(1)
  102. def create_session(credentials, tenant_id):
  103. """
  104. Create a session with the API and save the token for later use.
  105. :param credentials: tuple of (client_id, client_secret)
  106. :param tentant_id: str of tenant_id
  107. :return: Account class and email: str
  108. """
  109. my_protocol = MSOffice365Protocol(api_version='v2.0')
  110. token_backend = FileSystemTokenBackend(token_filename=access_token) # Put access token where the file is so it can always access it.
  111. return Account(
  112. credentials,
  113. protocol=my_protocol,
  114. tenant_id=tenant_id,
  115. token_backend=token_backend,
  116. raise_http_error=False
  117. )
  118. def authenticate_session(session: Account):
  119. """
  120. Authenticates account session object with oauth. Uses the default auth flow that comes with the library
  121. :param session: Account object
  122. :return: Bool for if the client is authenticated
  123. """
  124. if not isfile(f"{dirname(realpath(__file__))}/access_token"):
  125. try:
  126. session.authenticate(scopes=['basic', 'address_book', 'users', 'calendar_shared'])
  127. session.con.oauth_request("https://outlook.office.com/api/v2.0/me/", "get")
  128. logger.debug("Successfully tested new access_token")
  129. return True
  130. except:
  131. os.remove(access_token)
  132. logger.error("Could not authenticate. Please make sure the config has the correct client_id, client_secret, etc. Exiting...")
  133. exit(1)
  134. else:
  135. try:
  136. session.con.oauth_request("https://outlook.office.com/api/v2.0/me/", "get")
  137. logger.debug("Successfully tested current access_token")
  138. return True
  139. except:
  140. os.remove(access_token)
  141. logger.warning("Failed to authenticate with current access_token. Deleting access_token and running authentication again.")
  142. return False
  143. def get_week_datetime():
  144. """
  145. Gets the current week's Monday and Friday to be used to filter a calendar.
  146. If this script is ran during the work week (Monday-Friday), it will be the current week. If it is ran on the weekend, it will generate for next week.
  147. :return: Monday and Friday: Datetime object
  148. """
  149. today = datetime.now()
  150. weekday = today.weekday()
  151. # If this script is run during the week
  152. if weekday <= 4: # 0 = Monday, 6 = Sunday
  153. # - the hour and minutes to get start of the day instead of when the
  154. extra_time = timedelta(hours=today.hour, minutes=today.minute, seconds=today.second, microseconds=today.microsecond)
  155. monday = today - timedelta(days=weekday) - extra_time # Monday = 0-0, Friday = 4-4
  156. friday = (today + timedelta(days=4 - weekday)) - extra_time + timedelta(hours=23, minutes=59)
  157. return monday, friday
  158. # If the date the script is ran on is the weekend, do next week instead
  159. monday = monday + timedelta(days=7) # Monday = 0-0, Friday = 4-4
  160. friday = friday + timedelta(days=7) # Fri to Fri 4 + (4 - 4), Tues to Fri = 2 + (4 - 2)
  161. return monday, friday
  162. def get_event_range(beginning_of_week: datetime, connection: Connection, email: str):
  163. """
  164. Makes api call to grab a calender view within a 2 week window either side of the current week.
  165. :param beginning_of_week: datetime object for this weeks monday
  166. :param connection: a connection to the office365 api
  167. :return: dict of json response
  168. """
  169. base_url = "https://outlook.office.com/api/v2.0/"
  170. scope = f"users/{email}/CalendarView"
  171. # Create a range of dates for this week so we can catch long events within the search
  172. bottom_range = beginning_of_week - timedelta(days=14)
  173. top_range = beginning_of_week + timedelta(days=21)
  174. # Setup url
  175. date_range = "startDateTime={}&endDateTime={}".format(bottom_range.strftime(strftime_pattern), top_range.strftime(strftime_pattern))
  176. limit = "$top=150"
  177. select = "$select=Subject,Organizer,Start,End,Attendees"
  178. url = f"{base_url}{scope}?{date_range}&{select}&{limit}"
  179. r = connection.oauth_request(url, "get")
  180. return r.json()
  181. def add_attendees_to_ooo_list(attendees: list, ooo_list: list):
  182. """
  183. Function to aid the adding of attendees in a list to the list of people who will be out of office.
  184. :param attendees: list of attendees to event
  185. :param ooo_list: list of the current days out of office users
  186. :return: ooo_list once appended
  187. """
  188. for attendee in attendees:
  189. attendee_name = attendee["EmailAddress"]["Name"].split(" ")[0] # Get first name
  190. if attendee_name not in ooo_list.copy():
  191. ooo_list.append(attendee_name.lower()) # Converted to lowercase so program is case insensitive
  192. return ooo_list
  193. def get_ooo_list(email: str, connection: Connection):
  194. """
  195. Makes request and parses data into a list of users who will not be in the office
  196. :param email: string of the outofoffice email where the out of office calender is located
  197. :param connection: a connection to the office365 api
  198. :return: list of 5 lists representing a 5 day list. Each list contains the lowercase names of who is not in the office.
  199. """
  200. monday, friday = get_week_datetime()
  201. try:
  202. events = get_event_range(monday, connection, email)
  203. logger.debug("Received response for two week range from week beginning with {:%Y-%m-%d} from outofoffice account with email: {}".format(monday, email))
  204. except HTTPError as e:
  205. logger.error("Could not request CalendarView | {}".format(e.response))
  206. exit(1)
  207. events = events["value"]
  208. outofoffice = [[], [], [], [], []]
  209. for event in events:
  210. # removes last char due to microsoft's datetime using 7 sigfigs for microseconds, python uses 6
  211. start = datetime.strptime(event["Start"]["DateTime"][:-1], strptime_pattern)
  212. end = datetime.strptime(event["End"]["DateTime"][:-1], strptime_pattern)
  213. attendees = event["Attendees"]
  214. # remove outofoffice account by list comprehension
  215. attendees = [x for x in attendees if x["EmailAddress"]["Address"] != email]
  216. organizer = event["Organizer"]
  217. if not attendees and organizer["EmailAddress"]["Address"] != email:
  218. # Sometimes user will be the one who makes the event, not the outofoffice account. Get the organizer.
  219. attendees = [event["Organizer"]]
  220. if (end - start) <= timedelta(days=1):
  221. # Event is for one day only, check if it starts within the week
  222. if monday <= start <= friday:
  223. # Event is within the week we are looking at, add all attendees
  224. weekday = outofoffice[start.weekday()]
  225. if not attendees:
  226. logger.warning("Event '{}' has no attendees. Cannot add to outofoffice list.".format(event["Subject"]))
  227. weekday = add_attendees_to_ooo_list(attendees, weekday)
  228. else:
  229. # Check if long events cover the days of this week
  230. for x, day_array in enumerate(outofoffice.copy()):
  231. current_day = monday + timedelta(days=x)
  232. if start <= current_day <= end:
  233. # if day is inside of the long event
  234. outofoffice[x] = add_attendees_to_ooo_list(attendees, day_array)
  235. logger.debug("Parsed events and successfully created out of office list.")
  236. return outofoffice
  237. def create_ooo_csv(ooo: list, users: list, output_path: str):
  238. """
  239. Creates a csv of who is in the office and on what day.
  240. :param ooo: a list of lists representing each day of a 5 day week. Each day's list has users who are not in that day
  241. :param users: a list of names of people in the office
  242. :param output_path: a str representing the output path of the csv file
  243. """
  244. with open(output_path, 'w', newline='', encoding='utf-8') as fp:
  245. fieldnames = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday")
  246. writer = csv.DictWriter(fp, fieldnames=fieldnames)
  247. writer.writeheader()
  248. for user in users:
  249. row = {}
  250. for i, day in enumerate(fieldnames):
  251. # for each day, check if the user is in that day, if not do not write their name into that day.
  252. if user not in ooo[i]:
  253. row[day] = user
  254. else:
  255. row[day] = ""
  256. writer.writerow(row)
  257. logger.debug("Created csv file.")
  258. def main():
  259. """
  260. Main function that is ran on start up. Script is ran from here.
  261. """
  262. config = parse_args()
  263. client_id = config["client_id"]
  264. client_secret = config["client_secret"]
  265. tenant_id = config["tenant_id"]
  266. output_path = config["output_path"]
  267. users = config["users"]
  268. email = config["ooo_email"]
  269. session = create_session((client_id, client_secret), tenant_id)
  270. auth = False
  271. while not auth:
  272. auth = authenticate_session(session)
  273. logger.debug("Session created and authenticated. {}".format(session))
  274. ooo = get_ooo_list(email, session.con)
  275. create_ooo_csv(ooo, users, output_path)
  276. monday, friday = get_week_datetime()
  277. print("\nCreated CSV seating plan for week {:%a %d/%m/%Y} to {:%a %d/%m/%Y} at {}".format(monday, friday, abspath(output_path)))
  278. if __name__ == "__main__":
  279. # This is for running the file in testing, rather than installing via pip
  280. main()