diff --git a/.gitignore b/.gitignore index c518367..e238c91 100644 --- a/.gitignore +++ b/.gitignore @@ -8,15 +8,17 @@ # Testing /coverage -# Productiom +# React Build Files /build -# Misc -.DS_Store +# React local environment files .env.local .env.development.local .env.test.local .env.production.local + +# Misc +.DS_Store .vscode/ # Node Package Management @@ -28,3 +30,4 @@ yarn-error.log* /api/venv __pycache__/ venv-manager.log +/api/.env \ No newline at end of file diff --git a/Dev Environment Setup Guide.md b/Dev Environment Setup Guide.md index e80c78b..03ed436 100644 --- a/Dev Environment Setup Guide.md +++ b/Dev Environment Setup Guide.md @@ -252,4 +252,5 @@ All of the tools in this project are accessible as an npm task so you can intera | `kill:api` | Kills the runaway API process(es). | | `venv:create` | This will create a virtual environment in `/api/venv` and install requirements from `/api/requirements.txt`. | | `venv:delete` | This will delete the folder `/api/venv`. | -| `venv:reset` | This will run `venv:delete` then `venv:create`. | \ No newline at end of file +| `venv:reset` | This will run `venv:delete` then `venv:create`. | +| `venv:freeze` | Regenerates the API requirements.txt file and mitigates [this pip bug](https://github.com/pypa/pip/issues/4022). | diff --git a/README.md b/README.md index 36e6329..0b31b4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # webqueue2 A re-write of Purdue ECN's webqueue -![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-09-22%20at%201.48.58%20PM.png) +![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-12-03%20at%208.10.32%20PM.png) ## Stay Up To Date See what's being worked on with [the webqueue2 Project](https://github.itap.purdue.edu/ECN/webqueue2/projects/). diff --git a/api/ECNQueue.py b/api/ECNQueue.py index 89c4d87..2cfcf98 100644 --- a/api/ECNQueue.py +++ b/api/ECNQueue.py @@ -1,16 +1,47 @@ -# TODO: Add ECNQueue module documentation +"""A library for interacting with Purdue ECN's ticketing system. + +This library allows interacting with queue Items (called Items) and collections +of items (called Queues). + +Example: + # Create a single Item (ce100) + >>> item = Item("ce", 100) + # Get the sender's email address from an Item + >>> item = Item("ce", 100) + >>> item.userEmail + + # Create an entire Queue (ce) + >>> queue = Queue("ce") + # Get the number of items in a Queue + >>> queue = Queue("ce") + >>> numItems = len(queue) + + # Get all queues (and their items) + >>> queues = getQueues() + +Attributes: + queueDirectory: The directory to load queues from. + queuesToIgnore: Queues that will not be loaded when running getQueues() + +Raises: + # TODO: Add description(s) of when a ValueError is raised. + ValueError: [description] +""" #------------------------------------------------------------------------------# # Imports #------------------------------------------------------------------------------# -import os, time, email, re, datetime +import os +import time +import email +import re +import datetime from dateutil.parser import parse from dateutil import tz from typing import Union import json - #------------------------------------------------------------------------------# # Configuration #------------------------------------------------------------------------------# @@ -20,14 +51,31 @@ currentFileDirectory = os.path.dirname(currentFilePath) currentFileDirectoryParent = os.path.dirname(currentFileDirectory) queueDirectory = os.path.join(currentFileDirectoryParent, "q-snapshot") -#queueDirectory = "/usr/site/uds/qcopy/11" # Queues to not load in getQueues() -queuesToIgnore = ["archives", "drafts", "inbox"] +queuesToIgnore = ["archives", "drafts", "inbox", "coral"] + + + +#------------------------------------------------------------------------------# +# Utilities +#------------------------------------------------------------------------------# + +def isValidItemName(name: str) -> bool: + """Returns true if file name is a valid item name -# B/c some items don't have a From field -# See coral259 -queuesToIgnore.append("coral") + Example: + isValidItemName("21") -> true + isValidItemName("twentyone") -> false + + Args: + name (str): The name to test. + + Returns: + bool: Name is valid item name. + """ + itemPattern = re.compile("^[0123456789]{1,3}$") + return True if itemPattern.match(name) else False @@ -35,1167 +83,1266 @@ # Classes #------------------------------------------------------------------------------# class Item: - # TODO: Add Item class documentation - - def __init__(self, queue: str, number: int) -> None: - - self.queue = queue - try: - self.number = int(number) - except ValueError: - raise ValueError("Could not convert \"" + number + "\" to an integer") - #self.number = number - - self.__path = "/".join([queueDirectory, self.queue, str(self.number)]) - self.lastUpdated = self.__getLastUpdated() - self.__rawItem = self.__getRawItem() - self.headers = self.__parseHeaders() - self.content = self.__parseSections() - self.isLocked = self.__isLocked() - self.userEmail = self.__parseFromData(data="userEmail") - self.userName = self.__parseFromData(data="userName") - self.userAlias = self.__getUserAlias() - self.assignedTo = self.__getAssignedTo() - self.subject = self.__getMostRecentHeaderByType("Subject") - self.status = self.__getMostRecentHeaderByType("Status") - self.priority = self.__getMostRecentHeaderByType("Priority") - self.department = self.__getMostRecentHeaderByType("Department") - self.building = self.__getMostRecentHeaderByType("Building") - self.dateReceived = self.__getFormattedDate(self.__getMostRecentHeaderByType("Date")) - - self.jsonData = { - "queue": self.queue, - "number": self.number, - "lastUpdated": self.lastUpdated, - "headers": self.headers, - "content": self.content, - "isLocked": self.isLocked, - "userEmail": self.userEmail, - "userName": self.userName, - "userAlias": self.userAlias, - "assignedTo": self.assignedTo, - "subject": self.subject, - "status": self.status, - "priority": self.priority, - "department": self.department, - "building": self.building, - "dateReceived": self.dateReceived - } - - def __getLastUpdated(self) -> str: - """Returns last modified time of item reported by the filesystem in mm-dd-yy hh:mm am/pm format. - - Example: - 07-23-20 10:34 AM - - Returns: - str: last modified time of item reported by the filesystem in mm-dd-yy hh:mm am/pm format. - """ - unixTime = os.path.getmtime(self.__path) - formattedTime = time.strftime('%m-%d-%y %I:%M %p', time.localtime(unixTime)) - return formattedTime - - def __getRawItem(self) -> list: - """Returns a list of all lines in the item file - - Returns: - list: List of all the lines in the item file - """ - with open(self.__path, errors="replace") as file: - return file.readlines() - - def __getHeaderBoundary(self) -> int: - """Returns the 0 based line number where the Item headers stop. - - Example: The header end would be on line 13 - 12: X-ECN-Queue-Original-URL: - 13: - 14: I need help. - - Returns: - int: line number where the Item headers end - """ - for lineNumber, line in enumerate(self.__rawItem): - if line == "\n": - return lineNumber - - def __parseHeaders(self) -> list: - """Returns a list containing dictionaries of header type and data. - Removes queue prefixes and whitespace. - - Examples: - "[ce] QStatus: Dont Delete\\nFrom: Justin Campbell \\n" - becomes - [ - {"QStatus": "Don't Delete"}, - {"From": "Justin Campbell "} - ] - - Returns: - list: Header dicts - """ - headerString = "" - - # Remove '[queue] ' prefixes: - # Example: - # [ce] QTime-Updated-By: campb303 becomes - # QTime-Updated-By: campb303 - queuePrefixPattern = re.compile("\[.*\] {1}") - for lineNumber in range(self.__getHeaderBoundary()): - line = self.__rawItem[lineNumber] - lineHasQueuePrefix = queuePrefixPattern.match(line) - - if lineHasQueuePrefix: - queuePrefix = line[ lineHasQueuePrefix.regs[0][0] : lineHasQueuePrefix.regs[0][1]] - line = line.replace(queuePrefix, "") - - headerString += line - - message = email.message_from_string(headerString + "".join(self.__getContent())) - - headers = [] - for key in message.keys(): - headers.append( { "type": key, "content": message[key] } ) - - return headers - - # TODO: Implement attachment parsing - - def __getContent(self) -> list: - """Returns a dictionary of lines of the item body. - - Example: - "Hello. I need help.\\n\\n*** Status updated by: campb303 at: 6/23/2020 13:26:55 ***\\nDont Delete" becomes - [ - "Hello. I need help.\\n", - "\\n", - "*** Status updated by: campb303 at: 6/23/2020 13:26:55 ***\\n", - "Don't Delete", - ] - - Returns: - list: Lines of the body item. - """ - contentStart = self.__getHeaderBoundary() + 1 - contentEnd = len(self.__rawItem) - 1 - return self.__rawItem[ contentStart : contentEnd ] - - - def __parseSections(self) -> list: - # List of all item events - sections = [] - - contentStart = self.__getHeaderBoundary() + 1 - contentEnd = len(self.__rawItem) - 1 - - # List of assignments for the item - assignementLsit = self.__assignmentParsing(contentStart) - - # Appends each assignment individually to sections - for assignment in assignementLsit: - sections.append(assignment) - - # Checks for Directory Identifiers - if self.__rawItem[contentStart] == "\n" and self.__rawItem[contentStart + 1].startswith("\t"): - - directoryStartLine = contentStart + 1 - - # Parses the directory information and returns a dictionary of directory values - directoryInfo = self.__directoryParsing(directoryStartLine) - - # Appends Directory Information into the sections array - sections.append(directoryInfo) - - # Sets the initial message start to the next line after all directory lines and newlines - contentStart = contentStart + len(directoryInfo) + 1 - - # The start line, type, and end line for item events - sectionBoundaries = [] - - # Delimiter info - delimiters = [ - {"name": "edit", "pattern": "*** Edited"}, - {"name": "status", "pattern": "*** Status"}, - {"name": "replyToUser", "pattern": "*** Replied"}, - {"name": "replyFromUser", "pattern": "=== "}, - ] - - # Signifies that there is an initial message to parse - initialMessageSection = True - - # Parses the entire contents of the message, stores everything before any delimiter as the initial message - # and the line number of any delimiters as well as the type - for lineNumber in range(contentStart, contentEnd + 1): - - line = self.__rawItem[lineNumber] - - # Looks for a starting delimiter and explicity excludes the reply-from-user ending delimiter - if (line.startswith("*** Edited by: ") or - line.startswith("*** Replied by: ") or - line.startswith("*** Status updated by: ") or - line == "=== Additional information supplied by user ===\n" and not - line == "===============================================\n" - ): - - # Sets the delimiter type based on the pattern within the delimiters list - for delimiter in delimiters: - - if line.startswith(delimiter["pattern"]): - sectionBoundaries.append({"start": lineNumber, "type": delimiter["name"]}) - break - - # If a starting delimiter was encountered, then there is no initial message - if initialMessageSection: - initialMessageSection = False - - elif initialMessageSection == True: - # Delimiter not encountered yet, so append initial message starting line as the current lin number - sectionBoundaries.append({"start": lineNumber, "type": "initial_message"}) - initialMessageSection = False - - # Used to set the end line of the last delimiter - sectionBoundaries.append({"start": contentEnd + 1}) - - # Sets the end of the section boundary to the begining of the next section boundary - for boundaryIndex in range(0, len(sectionBoundaries) - 1): - - sectionBoundaries[boundaryIndex]["end"] = sectionBoundaries[boundaryIndex + 1]["start"] - - # Remove End of File boundary since the line number has been assigned to the last delimiter - del sectionBoundaries[-1] - - # Parses through all the boundaries in section boundaries - for boundary in sectionBoundaries: - - # Sets line to the first line of the boundary (which is always the delimiter) - line = self.__rawItem[boundary["start"]] - - # Returns all of the lines within the current section - sectionContent = self.__rawItem[boundary["start"] : boundary["end"]] - - # Appends an initial message dictionary to sections - if boundary["type"] == "initial_message": - initialMessageDictionary = self.__initialMessageParsing(sectionContent) - sections.append(initialMessageDictionary) - - elif boundary["type"] == "edit": - # Returns a dictionary with edit information - editInfo = self.__editParsing(sectionContent, boundary["start"]) - - # Checks for a parse error and appends it, returning the sections list which stops the parsing - if editInfo["type"] == "parse_error": - sections.append(editInfo) - return self.__getSortedSections(sections) - - # Appends the edit dictionary to sections - sections.append(editInfo) - - elif boundary["type"] == "replyToUser": - # Returns a dictionary with reply-to information - replyToInfo = self.__replyToParsing(sectionContent, boundary["start"]) - - # Checks for a parse error and appends it, returning the sections list which stops the parsing - if replyToInfo["type"] == "parse_error": - sections.append(replyToInfo) - return self.__getSortedSections(sections) - - # Appends the reply-to to sections - sections.append(replyToInfo) - - elif boundary["type"] == "status": - # Returns a dictionary with status information - statusInfo = self.__statusParsing(sectionContent, boundary["start"]) - - if statusInfo["type"] == "parse_error": - sections.append(statusInfo) - return self.__getSortedSections(sections) - - # Appends the status to sections - sections.append(statusInfo) - - elif boundary["type"] == "replyFromUser": - # Returns a dictionary with userReply information - replyFromInfo = self.__userReplyParsing(sectionContent, boundary["start"]) - - if replyFromInfo["type"] == "parse_error": - sections.append(replyFromInfo) - return self.__getSortedSections(sections) - - # Appends the replyFrom to sections - sections.append(replyFromInfo) - - sortedSections = self.__getSortedSections(sections) - - return sortedSections - #return sections - - def __directoryParsing(self, directoryStartLine: int) -> dict: - """Returns a dictionary with directory information - - Example: - Name: Nestor Fabian Rodriguez Buitrago - Login: rodri563 - Computer: ce-205-38 (128.46.205.67) - Location: HAMP G230 - Email: rodri563@purdue.edu - Phone: 7654766893 - Office: HAMP G230 - UNIX Dir: /home/bridge/b/rodri563 - Zero Dir: U=\\bridge.ecn.purdue.edu\rodri563 - User ECNDB: http://eng.purdue.edu/jump/2e8399a - Host ECNDB: http://eng.purdue.edu/jump/2e83999 - Subject: Autocad installation - - Args: - directoryStartLine (int): line number within the item that the directory starts on - - Returns: - dict: dictionary that splits each line within the directory into a key and a value - """ - directoryInformation = {"type": "directory_information"} - - directoryPossibleKeys=[ - "Name", - "Login", - "Computer", - "Location", - "Email", - "Phone", - "Office", - "UNIX Dir", - "Zero Dir", - "User ECNDB", - "Host ECNDB", - "Subject" - ] - # Executies until the directory start line is greater than the directory ending line - while True: - - # Returns the line number at directory start line - info = self.__rawItem[directoryStartLine] - - # Breaks the loop if it encountrs a newline, signifying the end of the directory information - if info == "\n": - - break - - else: - - # Removes white including space, newlines, and tabs from the directory info line - strippedInfo = info.strip() - - # Attempts to find ": " but will accept ":", denoting a blank entry for a directory item - if ": " in strippedInfo: - - # Seperates the directory info line into two variables, the first variable being the key, the second being the value - # swt1 - key, value = strippedInfo.split(": ", 1) - - if key in directoryPossibleKeys: - # Adds the key value pair to the directory info dictionary - directoryInformation[key] = value - else: - # Casts the list type on to a dictionary - dictionaryList = list(directoryInformation) - # Length of dictionary list - lenDictionaryList = len(dictionaryList) - # The last key appended to the directory dictionary - lastKeyAppended = dictionaryList[lenDictionaryList - 1] - - directoryInformation[lastKeyAppended] = directoryInformation[lastKeyAppended] + " " + strippedInfo - - elif ":" in strippedInfo: - - # Seperates the directory info line into two variables, the first variable being the key, the second being the value - key, value = strippedInfo.split(":", 1) - - if key in directoryPossibleKeys: - # Adds the key value pair to the directory info dictionary - directoryInformation[key] = value - else: - # Casts the list type on to a dictionary - dictionaryList = list(directoryInformation) - # Length of dictionary list - lenDictionaryList = len(dictionaryList) - # The last key appended to the directory dictionary - lastKeyAppended = dictionaryList[lenDictionaryList - 1] - - directoryInformation[lastKeyAppended] = directoryInformation[lastKeyAppended] + " " + strippedInfo - - # Signifies that this line belongs to the most previous line - elif ": " not in strippedInfo and ":" not in strippedInfo: - # Casts the list type on to a dictionary - dictionaryList = list(directoryInformation) - # Length of dictionary list - lenDictionaryList = len(dictionaryList) - # The last key appended to the directory dictionary - lastKeyAppended = dictionaryList[lenDictionaryList - 1] - - directoryInformation[lastKeyAppended] = directoryInformation[lastKeyAppended] + " " + strippedInfo - # Counter to denote the end of the directory - directoryStartLine = directoryStartLine + 1 - - # Returns the directory information dictionary - return directoryInformation - - def __assignmentParsing(self, contentStart: int) -> list: - """Returns a list with assignment information dictionaries - - Example: - Assigned-To: campb303 - Assigned-To-Updated-Time: Tue, 23 Jun 2020 13:27:00 EDT - Assigned-To-Updated-By: campb303 - - Args: - contentStart (int): line number where the content starts - - Returns: - list: [ - {"type": "assignment", - "datetime": datetime of the assignment, - "by": user who initiated the assignment, - "to": user who was assigned - }, - ] - """ - assignmentList =[] - - # Assignment Information - assignedBy = "" - assignedDateTime = "" - assignedTo = "" - - # Parses the header looking for assignment delimeters and stores info into their respective variables - for headerContent in range(0, contentStart): - - line = self.__rawItem[headerContent] - - # Gets who the Item was assigned to - if line.startswith("Assigned-To: "): - - assignedTo = (re.search("(?<=Assigned-To: )(.*)", line)).group() - - # Gets the date the Item was assigned - elif line.startswith("Assigned-To-Updated-Time: "): - - dateFromLine = (re.search("(?<=Assigned-To-Updated-Time: )(.*)", line)).group() - - assignedDateTime = self.__getFormattedDate(dateFromLine) - - # Gets who assigned the Item - elif line.startswith("Assigned-To-Updated-By: "): - - assignedBy = (re.search("(?<=Assigned-To-Updated-By: )(.*)", line)).group() - - # Appends the assignment to the sections list - assignmentList.append( - {"type": "assignment", - "datetime": assignedDateTime, - "by": assignedBy, - "to": assignedTo} - ) - - return assignmentList - - def __initialMessageParsing(self, content: list) -> dict: - """Returns a dictionary with initial message information - - Example: - \n - Testtest\n - \n - - Args: - content (list): content of the initial message - - Returns: - dict: - "type": "initial_message", - "datetime": datetime the initial message was sent, - "from_name": from_name, - "from_email": user_email, - "to": [{email, name}], - "cc": [{email, name}], - "subject": initial message subject - "content": content of the initial message - """ - initialMessageDictionary = {} - - initialMessageDictionary["type"] = "initial_message" - - # Gets the initial message date from the header - rawMessageDateStr = self.__getMostRecentHeaderByType("Date") - - # Sets datetime in the intialMessage dictionary to UTC formatted date - initialMessageDictionary["datetime"] = self.__getFormattedDate(rawMessageDateStr) - - initialMessageDictionary["from_name"] = self.__parseFromData(data="userName") - - initialMessageDictionary["from_email"] = self.__parseFromData(data="userEmail") - - # Stores list of dictionaries for the recipients of the initial message - initialMessageDictionary["to"] = [] - - # Parses the header looking for recipients of the initial message and stores it in a list of tuples - rawMessageRecipientsList = email.utils.getaddresses([self.__getMostRecentHeaderByType("To")]) - - # Parses the CC list and stores the cc recipient information in a list of dictionaries - for recipients in rawMessageRecipientsList: - - initialMessageDictionary["to"].append( - {"name": recipients[0], - "email": recipients[1]} - ) - - # Stores list of dictionaries for CC information - initialMessageDictionary["cc"] = [] - - # Parses the header looking for CC recipients of the initial message and stores it in a list of tuples - rawMessageCCList = email.utils.getaddresses([self.__getMostRecentHeaderByType("CC")]) - - # Parses the CC list and stores the cc recipient information in a list of dictionaries - for ccRecipients in rawMessageCCList: - - initialMessageDictionary["cc"].append( - {"name": ccRecipients[0], - "email": ccRecipients[1]} - ) - - initialMessageDictionary["subject"] = self.__getMostRecentHeaderByType("Subject") - - # Removes unecessary newlines from the begining and the end of the initial message - initialMessageDictionary["content"] = self.__getFormattedSectionContent(content) - - return initialMessageDictionary - - def __editParsing(self, content: list, lineNum: int) -> dict: - """Returns a dictionary with edit information - - Example: - *** Edited by: campb303 at: 06/23/20 13:27:56 ***\n - \n - This be an edit my boy\n - \n - \n - \n - - Args: - content (list): content of an edit - lineNum (int): line number of an edit within an item - - Returns: - dict: a dictionary with these keys, - "type": "edi", - "by": initiator of the edit, - "datetime": datetime of the edit, - "content": content of the edit - """ - - # Edit Info dictionary - editInfo = {} - - for count, line in enumerate(content): - if line == "===============================================\n" : - errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" - return self.__errorParsing(line, lineNum + count + 1, errorMessage) - - editInfo["type"] = "edit" - - delimiterLine = content[0] - # Parses for the author of the edit, which is located between the "*** Edited by: " and " at:" substrings - try: - editInfo["by"] = (re.search("(?<=\*{3} Edited by: )(.*)(?= at:)", delimiterLine)).group() - except: - errorMessage = "*** Edited by: [username] at: [date and time] ***\n" - return self.__errorParsing(delimiterLine, lineNum, errorMessage) - - try: - # Parses for the date and time of the edit, which is located between the " at: " and "***\n" substrings - dateTimeString = (re.search("(?<= at: )(.*)(?= \*\*\*\n)", delimiterLine)).group() - except: - # Returns an error message if there is no space after "at:" - errorMessage = "*** Edited by: [username] at: [date and time] ***\n" - return self.__errorParsing(delimiterLine, lineNum, errorMessage) - - # Attempts to format the date and time into utc format - editInfo["datetime"] = self.__getFormattedDate(dateTimeString) - - # Remove the delimiter String and unecessary newlines - editInfo["content"] = self.__getFormattedSectionContent(content) - - return editInfo - - def __replyToParsing(self, content: list, lineNum: int) -> dict: - """Returns a dictionary with reply to user information - - Example: - *** Replied by: campb303 at: 06/23/20 13:28:18 ***\n - \n - This be a reply my son\n - \n - Justin\n - ECN\n - \n - - Args: - content (list): content of a reply to user - lineNum (int): line number of a reply to user in an item - - Returns: - dict: a dictionary with these keys, - "type": "reply_to_user", - "by": initiator of the reply to user, - "datetime": datetime of the reply to user, - "content": content of the reply to user - """ - replyInfo = {} - - replyInfo["type"] = "reply_to_user" - - delimiterLine = content[0] - - for count, line in enumerate(content): - if line == "===============================================\n": - errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" - return self.__errorParsing(line, lineNum + count + 1, errorMessage) - - try: - # Parses for the author of the reply, which is located between the "*** Replied by: " and " at:" substrings - replyInfo["by"] = (re.search("(?<=\*{3} Replied by: )(.*)(?= at:)", delimiterLine)).group() - except: - errorMessage = "*** Replied by: [username] at: [date and time] ***\n" - return self.__errorParsing(delimiterLine, lineNum, errorMessage) - - # Parses for the date and time of the reply, which is located between the " at: " and "***\n" substrings - try: - dateTimeString = (re.search("(?<= at: )(.*)(?= \*\*\*\n)", delimiterLine)).group() - except: - errorMessage = "*** Replied by: [username] at: [date and time] ***\n" - return self.__errorParsing(delimiterLine, lineNum, errorMessage) - - # Formats date to UTC - replyInfo["datetime"] = self.__getFormattedDate(dateTimeString) - - replyInfo["content"] = self.__getFormattedSectionContent(content) - - return replyInfo - - def __statusParsing(self, content: list, lineNum: int) -> dict: - """Returns a dictionary with status information - - Example: - *** Status updated by: campb303 at: 6/23/2020 13:26:55 ***\n - Dont Delete\n - - Args: - content (list): The content of a status update - lineNum (int): The line number of a status update in an item - - Returns: - dict: a dictionary with these keys, - "type": "status", - "by": initiator of the status update, - "datetime": datetime of the status update, - "content": content of the status update - """ - statusInfo = {} - - statusInfo["type"] = "status" - - delimiterLine = content[0] - - for count, line in enumerate(content): - if line == "===============================================\n": - errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" - return self.__errorParsing(line, lineNum + count + 1, errorMessage) - - # Parses for the author of the status change, which is located between the "*** Status updated by: " and " at:" substrings - try: - statusInfo["by"] = (re.search("(?<=\*{3} Status updated by: )(.*)(?= at:)", delimiterLine)).group() - except: - errorMessage = "*** Status updated by: [username] at: [date and time] ***\n" - - return self.__errorParsing(delimiterLine, lineNum, errorMessage) - - # Parses for the date and time of the status change, which is located between the " at: " and "***\n" substrings - try: - dateTimeString = re.search("(?<= at: )(.*)(?= \*\*\*\n)", delimiterLine).group() - except: - errorMessage = "*** Status updated by: [username] at: [date and time] ***\n" - - return self.__errorParsing(delimiterLine, lineNum, errorMessage) - - # Formats the date to UTC - statusInfo["datetime"] = self.__getFormattedDate(dateTimeString) - - # Remove the delimiter String and unecessary newlines - statusInfo["content"] = self.__getFormattedSectionContent(content) - - return statusInfo - - def __userReplyParsing(self, replyContent: list, lineNumber: int) -> dict: - """Returns a dictionary with user reply information - - Example: - === Additional information supplied by user ===\n - \n - Subject: Re: Beepboop\n - From: Justin Campbell \n - Date: Tue, 23 Jun 2020 13:30:45 -0400\n - X-ECN-Queue-Original-Path: /home/pier/e/queue/Attachments/inbox/2020-06-23/212-original.txt\n - X-ECN-Queue-Original-URL: https://engineering.purdue.edu/webqueue/Attachments/inbox/2020-06-23/212-original.txt\n - \n - Huzzah!\n - \n - ===============================================\n - \n - Args: - replyContent (list): The entire section of a reply-from-user - lineNumber (int): The line number of the begining of a reply-from-user section within and item - - Returns: - dict: a dictionary with these keys, - "type": "reply_from_user", - "from_name": name of the user that sent the reply, - "from_email": email of the user that sent the reply, - "subject": subject of the reply, - "datetime": the datetime of the reply, - "cc": [ - {"name": name of the carbon copied recipient, - "email": email of the carbon copied recipient - }, - ] - "content": content of the reply - "headers": [ - {"type": headerType, - "content": content - }, - ] - """ - replyFromInfo = {} - - replyFromInfo["type"] = "reply_from_user" - - replyFromHeaders = [] - newLineCounter = 0 - endingDelimiterCount = 0 - - # Delimiter information line numbers to remove from reply from user - linesToRemove =[] - - # Parses the section content looking for any line that starts with a metadata, also tracks the line - # number with the enumerate function - for lineNum, line in enumerate(replyContent): - - if endingDelimiterCount == 0 and lineNum == len(replyContent) - 1: - errorMessage = "Did not encounter a reply-from-user ending delimiter" - return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) - - if newLineCounter == 1 and line != "\n": - - try: - # Append header information for each headr line - headerType, content = line.split(": ", 1) - replyFromHeaders.append( - {"type": headerType, - "content": content - } - ) - except: - lenReplyFromHeaders = len(replyFromHeaders) - if lenReplyFromHeaders == 0: - errorMessage = ("Expected reply-from-user header information:\n" + - "=== Additional information supplied by user ===\n" + - "\n" + - "[Header Type]: [Header Value]\n" + - "\n" - ) - return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) - - else: - replyFromHeaders[lenReplyFromHeaders - 1]["content"] = replyFromHeaders[lenReplyFromHeaders - 1]["content"] + " " + line - - linesToRemove.append(lineNum) - #Checks for a newline and breaks for loop on second occurance of a newline - if line == "\n": - newLineCounter = newLineCounter + 1 - - elif line == "===============================================\n": - endingDelimiterCount = endingDelimiterCount + 1 - - elif line.startswith("From: ") and newLineCounter == 1: - # Returns a list of one tuples with a name stored in the first index of the tuple and an email stored in the second index of the tuple - emailList = email.utils.getaddresses([line]) - replyFromInfo["from_name"] = emailList[0][0] - replyFromInfo["from_email"] = emailList[0][1] - - elif line.startswith("Subject: ") and newLineCounter == 1: - # Matches everything after "Subject: " - try: - subjectStr = (re.search("(?<=Subject: )(.*)", line)).group() - except: - errorMessage = "Expeted syntax of \"Subject: [subject]\"" - return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) - - # Formatts the date to UTC - replyFromInfo["subject"] = subjectStr - - elif line.startswith("Date: ") and newLineCounter == 1: - # Matches everything after "Date: " - try: - dateStr = (re.search("(?<=Date: )(.*)", line)).group() - except: - errorMessage = "\"Date: [datetime]\"" - return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) - - # Formatts the date to UTC - replyFromInfo["datetime"] = self.__getFormattedDate(dateStr) - - elif line.startswith("Cc: ") and newLineCounter == 1: - - replyFromInfo["cc"] = [] - - # Returns a list of tuples with email information - recipientsList = email.utils.getaddresses([line]) - - # Parses through the cc tuple list - for cc in recipientsList: - # Stores the cc information in a dictionary and appends it to the ccRecipientsList - replyFromInfo["cc"].append( - {"name": cc[0], - "email": cc[1]} - ) - - # Deletes reduntant lines from the message content in reverse order - for lineNum in sorted(linesToRemove, reverse = True): - replyContent.pop(lineNum) - - # Strips any unnecessary newlines or any delimiters frm the message content - replyFromInfo["content"] = self.__getFormattedSectionContent(replyContent) - - replyFromInfo["headers"] = replyFromHeaders - - return replyFromInfo - - def __getFormattedSectionContent(self, sectionContent: list) -> list: - """Returns a list with message content that is stripped of unnecessary newlines and begining delimiters - - Example: - *** Edited by: mph at: 02/21/20 10:27:16 ***\n - \n - Still need to rename machines - but the networking issue now seems to \n - be resolved via another ticket.\n - \n - \n - \n - \n - \n - - Args: - sectionContent (list): The section content of a parsed section - - Returns: - list: the section content of a parsed section without any delimiters and unnecessary newlines - """ - # Continually removes the first line of sectionContent if it is a newline or delimiter in each iteration - while len(sectionContent) > 1: - if (sectionContent[0] == "\n" or - sectionContent[0].startswith("*** Edited by: ") or - sectionContent[0].startswith("*** Replied by: ") or - sectionContent[0].startswith("*** Status updated by: ") or - sectionContent[0] == "=== Additional information supplied by user ===\n" or - sectionContent[0] == "===============================================\n" - ): - sectionContent.pop(0) - else: - # Breaks the loop if the first line isn't a newline or delimiter - break - - # Continually removes the last line of sectionContent if it is a newline or delimiter in each iteration - while len(sectionContent) > 1: - # Initializes the Length of sectionContent each iteration of the loop - sectionContentLength = len(sectionContent) - - if (sectionContent[sectionContentLength -1] == "\n" or - sectionContent[sectionContentLength -1] == "===============================================\n" - ): - sectionContent.pop(sectionContentLength - 1) - else: - # Breaks the loop if the last line isn't a newline or delimiter - break - - return sectionContent - - def __errorParsing(self, line: str, lineNum: int, expectedSyntax: str) -> dict: - """Returns a dictionary with error parse information when a line is malformed - - Example: - "*** Status updated by: ewhile at: 5/7/2020 10:59:11 *** sharing between\n" - - Args: - line (str): line of that threw error - lineNum (int): line number in the item that threw error - expectedSyntax (str): a message stating the syntax the line should follow - - Returns: - dict: a dictionary with these keys, - "type": "parse_error", - "datetime": time the error was encountered, - "file_path": path of the item with erroneos line, - "expected": expectedSyntax, - "got": line, - "line_num": lineNum - """ - errorDictionary = {} - - # Type - errorDictionary["type"] = "parse_error" - - # Dateime of the parse error - errorDictionary["datetime"] = self.__getFormattedDate(str(datetime.datetime.now())) - - # Item filepath - errorDictionary["file_path"] = self.__path - - # Expected value - errorDictionary["expected"] = expectedSyntax - - # line that threw error - errorDictionary["got"] = line - - # line number that threw error - errorDictionary["line_num"] = lineNum - - # returns the error dictionary - return errorDictionary - - def __getSortedSections(self, sectionsList: list) -> list: - """Sorts the sections chronologically by datetime - - Example: - [example] need to do - - Args: - sections (list): the list of sections to be sorted - - Returns: - list: a list of sections sorted by datetime - """ - sectionsLength = len(sectionsList) - sortedSections = [] - oldestSection = {} - - while len(sortedSections) < sectionsLength: - - for iteration, currentSection in enumerate(sectionsList): - - if currentSection["type"] == "directory_information": - sortedSections.append(currentSection) - sectionsList.remove(currentSection) - break - - if iteration == 0: - oldestSection = currentSection - - #datetime.datetime.strptime(date_time_str, '%Y-%m-%d %H:%M:%S.%f') - - elif parse(currentSection["datetime"]) < parse(oldestSection["datetime"]): - oldestSection = currentSection - - if iteration == len(sectionsList) - 1: - sortedSections.append(oldestSection) - sectionsList.remove(oldestSection) - - return sortedSections - - - def __isLocked(self) -> Union[str, bool]: - """Returns a string info about the lock if true and a bool False if false - - Example: A file is locked - "CE 100 is locked by campb303 using qvi" - - Example: a file is not locked - False - - Returns: - Union[str, bool]: String with info about lock if true, bool False if false - """ - lockFile = self.__path + ".lck" - if os.path.exists(lockFile): - with open(lockFile) as file: - lockInfo = file.readline().split(" ") - lockedBy = lockInfo[4] - lockedUsing = lockInfo[1] - return "{queue} {number} is locked by {lockedBy} using {lockedUsing}".format(queue=self.queue, number=self.number, lockedBy=lockedBy, lockedUsing=lockedUsing) - else: - return False - - def __getMostRecentHeaderByType(self, headerType: str) -> str: - """Return the data of most recent header of the given type. - If no header of that type exists, return an empty string. - - Example: Requesting a Status header that does exist - __getMostRecentHeaderByType("Status") - becomes "Waiting for Reply" - - Example: Requesting a Status header that doesn't exist - __getMostRecentHeaderByType("Status") - becomes "" - - Args: - headerType (str): Type of header to return. - - Returns: - str: data of most recent header of the given type or empty string. - """ - for header in self.headers: - if header["type"] == headerType: return header["content"] - return "" - - def __parseFromData(self, data: str) -> str: - """Parse From header and return requested data. - Returns empty string if requested data is unavailable. - - Examples: From data is "From: Campbell, Justin " - __parseFromData(data="userName") returns "Campbell, Justin" - __parseFromData(data="userEmail") returns "campb303@purdue.edu" - - Args: - data (str): The data desired; can be "userName" or "userEmail". - - Returns: - str: userName, userEmail or empty string. - """ - fromHeader = self.__getMostRecentHeaderByType("From") - userName, userEmail = email.utils.parseaddr(fromHeader) - - if data == "userName": return userName - elif data == "userEmail": return userEmail - else: raise ValueError("data='" + str(data) + "' is not a valid option. data must be \"userName\" or \"userEmail\".") - - def __getUserAlias(self) -> str: - """Returns user's Career Account alias if present. - If Career Account alias isn't present, returns empty string. - - Example: Email from campb303@purdue.edu - userAlias = "campb303" - - Example: Email from spam@spammer.net - userAlias = "" - - Returns: - str: User's Career Account alias if present or empty string - """ - emailUser, emailDomain = self.userEmail.split("@") - return emailUser if emailDomain.endswith("purdue.edu") else "" - - def __getAssignedTo(self) -> str: - """Returns the alias of the person this item was most recently assigned to. - Returns empty string if this item isn't assigned. - - Returns: - str: Alias of the person item is assigned to or empty string. - """ - assignedTo = self.__getMostRecentHeaderByType("Assigned-To") - return assignedTo - - def __getFormattedDate(self, date: str) -> str: - """Returns the date/time formatted as RFC 8601 YYYY-MM-DDTHH:MM:SS+00:00. - Returns empty string if the string argument passed to the function is not a datetime. - See: https://en.wikipedia.org/wiki/ISO_8601 - - Returns: - str: Properly formatted date/time recieved or empty string. - """ - try: - parsedDate = parse(date, default= datetime.datetime(2017, 10, 13, tzinfo=tz.gettz('EDT'))) - #parsedDate = parse(date, default= datetime.datetime(2017, 10, 13, tzinfo=datetime.timezone.) - except: - return "" - - parsedDateString = parsedDate.strftime("%Y-%m-%dT%H:%M:%S%z") - - return parsedDateString - - def toJson(self) -> dict: - """Returns a JSON safe representation of the item. - - Returns: - dict: JSON safe representation of the item. - """ - return self.jsonData - - def __repr__(self) -> str: - return self.queue + str(self.number) + """A single issue. + + Example: + # Create an Item (ce100) + >>> item = Item("ce", 100) + + Attributes: + lastUpdated: An ISO 8601 formatted time string showing the last time the file was updated according to the filesystem. + headers: A list of dictionaries containing header keys and values. + content: A list of section dictionaries. + isLocked: A boolean showing whether or not a lockfile for the item is present. + userEmail: The email address of the person who this item is from. + userName: The real name of the person who this item is from. + userAlias: The Purdue career account alias of the person this item is from. + assignedTo: The Purdue career account alias of the person this item is assigned to + subject: The subject of the original message for this item. + status: The most recent status update for the item. + priority: The most recent priority for this item. + department: The most recent department for this item. + dateReceived: The date this item was created. + jsonData: A JSON serializable representation of the Item. + """ + + def __init__(self, queue: str, number: int) -> None: + self.queue = queue + try: + self.number = int(number) + except ValueError: + raise ValueError(" Could not convert \"" + + number + "\" to an integer") + + self.__path = "/".join([queueDirectory, self.queue, str(self.number)]) + self.lastUpdated = self.__getLastUpdated() + self.__rawItem = self.__getRawItem() + self.headers = self.__parseHeaders() + self.content = self.__parseSections() + self.isLocked = self.__isLocked() + self.userEmail = self.__parseFromData(data="userEmail") + self.userName = self.__parseFromData(data="userName") + self.userAlias = self.__getUserAlias() + self.assignedTo = self.__getMostRecentHeaderByType("Assigned-To") + self.subject = self.__getMostRecentHeaderByType("Subject") + self.status = self.__getMostRecentHeaderByType("Status") + self.priority = self.__getMostRecentHeaderByType("Priority") + self.department = self.__getMostRecentHeaderByType("Department") + self.building = self.__getMostRecentHeaderByType("Building") + self.dateReceived = self.__getFormattedDate( + self.__getMostRecentHeaderByType("Date")) + + # TODO: Autopopulate jsonData w/ __dir__() command. Exclude `^_` and `jsonData`. + self.jsonData = { + "queue": self.queue, + "number": self.number, + "lastUpdated": self.lastUpdated, + "headers": self.headers, + "content": self.content, + "isLocked": self.isLocked, + "userEmail": self.userEmail, + "userName": self.userName, + "userAlias": self.userAlias, + "assignedTo": self.assignedTo, + "subject": self.subject, + "status": self.status, + "priority": self.priority, + "department": self.department, + "building": self.building, + "dateReceived": self.dateReceived + } + + def __getLastUpdated(self) -> str: + """Returns last modified time of item reported by the filesystem in mm-dd-yy hh:mm am/pm format. + + Example: + 07-23-20 10:34 AM + + Returns: + str: last modified time of item reported by the filesystem in mm-dd-yy hh:mm am/pm format. + """ + # TODO: Simplify this code block by allowing __getFormattedDate to accept milliseconds since the epoch. + unixTime = os.path.getmtime(self.__path) + formattedTime = time.strftime( + '%m-%d-%y %I:%M %p', time.localtime(unixTime)) + return self.__getFormattedDate(formattedTime) + + def __getRawItem(self) -> list: + """Returns a list of all lines in the item file + + Returns: + list: List of all the lines in the item file + """ + with open(self.__path, errors="replace") as file: + return file.readlines() + + def __getHeaderBoundary(self) -> int: + """Returns the 0 based line number where the Item headers stop. + + Example: The header end would be on line 13 + 12: X-ECN-Queue-Original-URL: + 13: + 14: I need help. + + Returns: + int: line number where the Item headers end + """ + for lineNumber, line in enumerate(self.__rawItem): + if line == "\n": + return lineNumber + + def __parseHeaders(self) -> list: + """Returns a list containing dictionaries of header type and data. + Removes queue prefixes and whitespace. + + Examples: + "[ce] QStatus: Dont Delete\\nFrom: Justin Campbell \\n" + becomes + [ + {"QStatus": "Don't Delete"}, + {"From": "Justin Campbell "} + ] + + Returns: + list: Header dicts + """ + headerString = "" + + # Remove '[queue] ' prefixes: + # Example: + # [ce] QTime-Updated-By: campb303 becomes + # QTime-Updated-By: campb303 + queuePrefixPattern = re.compile("\[.*\] {1}") + for lineNumber in range(self.__getHeaderBoundary()): + line = self.__rawItem[lineNumber] + lineHasQueuePrefix = queuePrefixPattern.match(line) + + if lineHasQueuePrefix: + queuePrefix = line[lineHasQueuePrefix.regs[0] + [0]: lineHasQueuePrefix.regs[0][1]] + line = line.replace(queuePrefix, "") + + headerString += line + + # message = email.message_from_string(headerString + "".join(self.__getContent())) + message = email.message_from_string(headerString) + + headers = [] + dateHeaders=[ + "QStatus-Updated-Time", + "Status-Updated-Time", + "Edited-Time", + "QTime-Updated-Time", + "Merged-Time", + "Time-Updated-Time", + "Replied-Time", + "Assigned-To-Updated-Time", + "QAssigned-To-Updated-Time", + "Date", + "Sent" + ] + + for key in message.keys(): + headers.append({"type": key, "content": self.__getFormattedDate(message[key]) if key in dateHeaders else message[key]}) + + return headers + + # TODO: Implement attachment parsing + + def __parseSections(self) -> list: + # List of all item events + sections = [] + + contentStart = self.__getHeaderBoundary() + 1 + contentEnd = len(self.__rawItem) - 1 + + # List of assignments for the item + assignementLsit = self.__assignmentParsing(contentStart) + + # Appends each assignment individually to sections + for assignment in assignementLsit: + sections.append(assignment) + + # Checks for Directory Identifiers + if self.__rawItem[contentStart] == "\n" and self.__rawItem[contentStart + 1].startswith("\t"): + + directoryStartLine = contentStart + 1 + + # Parses the directory information and returns a dictionary of directory values + directoryInfo = self.__directoryParsing(directoryStartLine) + + # Appends Directory Information into the sections array + sections.append(directoryInfo) + + # Sets the initial message start to the next line after all directory lines and newlines + contentStart = contentStart + len(directoryInfo) + 1 + + # The start line, type, and end line for item events + sectionBoundaries = [] + + # Delimiter info + delimiters = [ + {"name": "edit", "pattern": "*** Edited"}, + {"name": "status", "pattern": "*** Status"}, + {"name": "replyToUser", "pattern": "*** Replied"}, + {"name": "replyFromUser", "pattern": "=== "}, + ] + + # Signifies that there is an initial message to parse + initialMessageSection = True + + # Parses the entire contents of the message, stores everything before any delimiter as the initial message + # and the line number of any delimiters as well as the type + for lineNumber in range(contentStart, contentEnd + 1): + + line = self.__rawItem[lineNumber] + + # Looks for a starting delimiter and explicity excludes the reply-from-user ending delimiter + if (line.startswith("*** Edited by: ") or + line.startswith("*** Replied by: ") or + line.startswith("*** Status updated by: ") or + line == "=== Additional information supplied by user ===\n" and not + line == "===============================================\n" + ): + + # Sets the delimiter type based on the pattern within the delimiters list + for delimiter in delimiters: + + if line.startswith(delimiter["pattern"]): + sectionBoundaries.append( + {"start": lineNumber, "type": delimiter["name"]}) + break + + # If a starting delimiter was encountered, then there is no initial message + if initialMessageSection: + initialMessageSection = False + + elif initialMessageSection == True: + # Delimiter not encountered yet, so append initial message starting line as the current lin number + sectionBoundaries.append( + {"start": lineNumber, "type": "initial_message"}) + initialMessageSection = False + + # Used to set the end line of the last delimiter + sectionBoundaries.append({"start": contentEnd + 1}) + + # Sets the end of the section boundary to the begining of the next section boundary + for boundaryIndex in range(0, len(sectionBoundaries) - 1): + + sectionBoundaries[boundaryIndex]["end"] = sectionBoundaries[boundaryIndex + 1]["start"] + + # Remove End of File boundary since the line number has been assigned to the last delimiter + del sectionBoundaries[-1] + + # Parses through all the boundaries in section boundaries + for boundary in sectionBoundaries: + + # Sets line to the first line of the boundary (which is always the delimiter) + line = self.__rawItem[boundary["start"]] + + # Returns all of the lines within the current section + sectionContent = self.__rawItem[boundary["start"]: boundary["end"]] + + # Appends an initial message dictionary to sections + if boundary["type"] == "initial_message": + initialMessageDictionary = self.__initialMessageParsing( + sectionContent) + sections.append(initialMessageDictionary) + + elif boundary["type"] == "edit": + # Returns a dictionary with edit information + editInfo = self.__editParsing( + sectionContent, boundary["start"]) + + # Checks for a parse error and appends it, returning the sections list which stops the parsing + if editInfo["type"] == "parse_error": + sections.append(editInfo) + return self.__getSortedSections(sections) + + # Appends the edit dictionary to sections + sections.append(editInfo) + + elif boundary["type"] == "replyToUser": + # Returns a dictionary with reply-to information + replyToInfo = self.__replyToParsing( + sectionContent, boundary["start"]) + + # Checks for a parse error and appends it, returning the sections list which stops the parsing + if replyToInfo["type"] == "parse_error": + sections.append(replyToInfo) + return self.__getSortedSections(sections) + + # Appends the reply-to to sections + sections.append(replyToInfo) + + elif boundary["type"] == "status": + # Returns a dictionary with status information + statusInfo = self.__statusParsing( + sectionContent, boundary["start"]) + + if statusInfo["type"] == "parse_error": + sections.append(statusInfo) + return self.__getSortedSections(sections) + + # Appends the status to sections + sections.append(statusInfo) + + elif boundary["type"] == "replyFromUser": + # Returns a dictionary with userReply information + replyFromInfo = self.__userReplyParsing( + sectionContent, boundary["start"]) + + if replyFromInfo["type"] == "parse_error": + sections.append(replyFromInfo) + return self.__getSortedSections(sections) + + # Appends the replyFrom to sections + sections.append(replyFromInfo) + + sortedSections = self.__getSortedSections(sections) + + return sortedSections + # return sections + + def __directoryParsing(self, directoryStartLine: int) -> dict: + """Returns a dictionary with directory information + + Example: + Name: Nestor Fabian Rodriguez Buitrago + Login: rodri563 + Computer: ce-205-38 (128.46.205.67) + Location: HAMP G230 + Email: rodri563@purdue.edu + Phone: 7654766893 + Office: HAMP G230 + UNIX Dir: /home/bridge/b/rodri563 + Zero Dir: U=\\bridge.ecn.purdue.edu\rodri563 + User ECNDB: http://eng.purdue.edu/jump/2e8399a + Host ECNDB: http://eng.purdue.edu/jump/2e83999 + Subject: Autocad installation + + Args: + directoryStartLine (int): line number within the item that the directory starts on + + Returns: + dict: dictionary that splits each line within the directory into a key and a value + """ + directoryInformation = {"type": "directory_information"} + + directoryPossibleKeys = [ + "Name", + "Login", + "Computer", + "Location", + "Email", + "Phone", + "Office", + "UNIX Dir", + "Zero Dir", + "User ECNDB", + "Host ECNDB", + "Subject" + ] + # Executies until the directory start line is greater than the directory ending line + while True: + + # Returns the line number at directory start line + info = self.__rawItem[directoryStartLine] + + # Breaks the loop if it encountrs a newline, signifying the end of the directory information + if info == "\n": + + break + + else: + + # Removes white including space, newlines, and tabs from the directory info line + strippedInfo = info.strip() + + # Attempts to find ": " but will accept ":", denoting a blank entry for a directory item + if ": " in strippedInfo: + + # Seperates the directory info line into two variables, the first variable being the key, the second being the value + # swt1 + key, value = strippedInfo.split(": ", 1) + + if key in directoryPossibleKeys: + # Adds the key value pair to the directory info dictionary + directoryInformation[key] = value + else: + # Casts the list type on to a dictionary + dictionaryList = list(directoryInformation) + # Length of dictionary list + lenDictionaryList = len(dictionaryList) + # The last key appended to the directory dictionary + lastKeyAppended = dictionaryList[lenDictionaryList - 1] + + directoryInformation[lastKeyAppended] = directoryInformation[lastKeyAppended] + \ + " " + strippedInfo + + elif ":" in strippedInfo: + + # Seperates the directory info line into two variables, the first variable being the key, the second being the value + key, value = strippedInfo.split(":", 1) + + if key in directoryPossibleKeys: + # Adds the key value pair to the directory info dictionary + directoryInformation[key] = value + else: + # Casts the list type on to a dictionary + dictionaryList = list(directoryInformation) + # Length of dictionary list + lenDictionaryList = len(dictionaryList) + # The last key appended to the directory dictionary + lastKeyAppended = dictionaryList[lenDictionaryList - 1] + + directoryInformation[lastKeyAppended] = directoryInformation[lastKeyAppended] + \ + " " + strippedInfo + + # Signifies that this line belongs to the most previous line + elif ": " not in strippedInfo and ":" not in strippedInfo: + # Casts the list type on to a dictionary + dictionaryList = list(directoryInformation) + # Length of dictionary list + lenDictionaryList = len(dictionaryList) + # The last key appended to the directory dictionary + lastKeyAppended = dictionaryList[lenDictionaryList - 1] + + directoryInformation[lastKeyAppended] = directoryInformation[lastKeyAppended] + \ + " " + strippedInfo + # Counter to denote the end of the directory + directoryStartLine = directoryStartLine + 1 + + # Returns the directory information dictionary + return directoryInformation + + def __assignmentParsing(self, contentStart: int) -> list: + """Returns a list with assignment information dictionaries + + Example: + Assigned-To: campb303 + Assigned-To-Updated-Time: Tue, 23 Jun 2020 13:27:00 EDT + Assigned-To-Updated-By: campb303 + + Args: + contentStart (int): line number where the content starts + + Returns: + list: [ + {"type": "assignment", + "datetime": datetime of the assignment, + "by": user who initiated the assignment, + "to": user who was assigned + }, + ] + """ + assignmentList = [] + + # Assignment Information + assignedBy = "" + assignedDateTime = "" + assignedTo = "" + + # Parses the header looking for assignment delimeters and stores info into their respective variables + for headerContent in range(0, contentStart): + + line = self.__rawItem[headerContent] + + # Gets who the Item was assigned to + if line.startswith("Assigned-To: "): + + assignedTo = ( + re.search("(?<=Assigned-To: )(.*)", line)).group() + + # Gets the date the Item was assigned + elif line.startswith("Assigned-To-Updated-Time: "): + + dateFromLine = ( + re.search("(?<=Assigned-To-Updated-Time: )(.*)", line)).group() + + assignedDateTime = self.__getFormattedDate(dateFromLine) + + # Gets who assigned the Item + elif line.startswith("Assigned-To-Updated-By: "): + + assignedBy = ( + re.search("(?<=Assigned-To-Updated-By: )(.*)", line)).group() + + # Appends the assignment to the sections list + assignmentList.append( + {"type": "assignment", + "datetime": assignedDateTime, + "by": assignedBy, + "to": assignedTo} + ) + + return assignmentList + + def __initialMessageParsing(self, content: list) -> dict: + """Returns a dictionary with initial message information + + Example: + \n + Testtest\n + \n + + Args: + content (list): content of the initial message + + Returns: + dict: + "type": "initial_message", + "datetime": datetime the initial message was sent, + "from_name": from_name, + "from_email": user_email, + "to": [{email, name}], + "cc": [{email, name}], + "subject": initial message subject + "content": content of the initial message + """ + initialMessageDictionary = {} + + initialMessageDictionary["type"] = "initial_message" + + # Gets the initial message date from the header + rawMessageDateStr = self.__getMostRecentHeaderByType("Date") + + # Sets datetime in the intialMessage dictionary to UTC formatted date + initialMessageDictionary["datetime"] = self.__getFormattedDate( + rawMessageDateStr) + + initialMessageDictionary["from_name"] = self.__parseFromData( + data="userName") + + initialMessageDictionary["from_email"] = self.__parseFromData( + data="userEmail") + + # Stores list of dictionaries for the recipients of the initial message + initialMessageDictionary["to"] = [] + + # Parses the header looking for recipients of the initial message and stores it in a list of tuples + rawMessageRecipientsList = email.utils.getaddresses( + [self.__getMostRecentHeaderByType("To")]) + + # Parses the CC list and stores the cc recipient information in a list of dictionaries + for recipients in rawMessageRecipientsList: + + initialMessageDictionary["to"].append( + {"name": recipients[0], + "email": recipients[1]} + ) + + # Stores list of dictionaries for CC information + initialMessageDictionary["cc"] = [] + + # Parses the header looking for CC recipients of the initial message and stores it in a list of tuples + rawMessageCCList = email.utils.getaddresses( + [self.__getMostRecentHeaderByType("CC")]) + + # Parses the CC list and stores the cc recipient information in a list of dictionaries + for ccRecipients in rawMessageCCList: + + initialMessageDictionary["cc"].append( + {"name": ccRecipients[0], + "email": ccRecipients[1]} + ) + + initialMessageDictionary["subject"] = self.__getMostRecentHeaderByType( + "Subject") + + # Removes unecessary newlines from the begining and the end of the initial message + initialMessageDictionary["content"] = self.__getFormattedSectionContent( + content) + + return initialMessageDictionary + + def __editParsing(self, content: list, lineNum: int) -> dict: + """Returns a dictionary with edit information + + Example: + *** Edited by: campb303 at: 06/23/20 13:27:56 ***\n + \n + This be an edit my boy\n + \n + \n + \n + + Args: + content (list): content of an edit + lineNum (int): line number of an edit within an item + + Returns: + dict: a dictionary with these keys, + "type": "edi", + "by": initiator of the edit, + "datetime": datetime of the edit, + "content": content of the edit + """ + + # Edit Info dictionary + editInfo = {} + + for count, line in enumerate(content): + if line == "===============================================\n": + errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" + return self.__errorParsing(line, lineNum + count + 1, errorMessage) + + editInfo["type"] = "edit" + + delimiterLine = content[0] + # Parses for the author of the edit, which is located between the "*** Edited by: " and " at:" substrings + try: + editInfo["by"] = ( + re.search("(?<=\*{3} Edited by: )(.*)(?= at:)", delimiterLine)).group() + except: + errorMessage = "*** Edited by: [username] at: [date and time] ***\n" + return self.__errorParsing(delimiterLine, lineNum, errorMessage) + + try: + # Parses for the date and time of the edit, which is located between the " at: " and "***\n" substrings + dateTimeString = ( + re.search("(?<= at: )(.*)(?= \*\*\*\n)", delimiterLine)).group() + except: + # Returns an error message if there is no space after "at:" + errorMessage = "*** Edited by: [username] at: [date and time] ***\n" + return self.__errorParsing(delimiterLine, lineNum, errorMessage) + + # Attempts to format the date and time into utc format + editInfo["datetime"] = self.__getFormattedDate(dateTimeString) + + # Remove the delimiter String and unecessary newlines + editInfo["content"] = self.__getFormattedSectionContent(content) + + return editInfo + + def __replyToParsing(self, content: list, lineNum: int) -> dict: + """Returns a dictionary with reply to user information + + Example: + *** Replied by: campb303 at: 06/23/20 13:28:18 ***\n + \n + This be a reply my son\n + \n + Justin\n + ECN\n + \n + + Args: + content (list): content of a reply to user + lineNum (int): line number of a reply to user in an item + + Returns: + dict: a dictionary with these keys, + "type": "reply_to_user", + "by": initiator of the reply to user, + "datetime": datetime of the reply to user, + "content": content of the reply to user + """ + replyInfo = {} + + replyInfo["type"] = "reply_to_user" + + delimiterLine = content[0] + + for count, line in enumerate(content): + if line == "===============================================\n": + errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" + return self.__errorParsing(line, lineNum + count + 1, errorMessage) + + try: + # Parses for the author of the reply, which is located between the "*** Replied by: " and " at:" substrings + replyInfo["by"] = ( + re.search("(?<=\*{3} Replied by: )(.*)(?= at:)", delimiterLine)).group() + except: + errorMessage = "*** Replied by: [username] at: [date and time] ***\n" + return self.__errorParsing(delimiterLine, lineNum, errorMessage) + + # Parses for the date and time of the reply, which is located between the " at: " and "***\n" substrings + try: + dateTimeString = ( + re.search("(?<= at: )(.*)(?= \*\*\*\n)", delimiterLine)).group() + except: + errorMessage = "*** Replied by: [username] at: [date and time] ***\n" + return self.__errorParsing(delimiterLine, lineNum, errorMessage) + + # Formats date to UTC + replyInfo["datetime"] = self.__getFormattedDate(dateTimeString) + + replyInfo["content"] = self.__getFormattedSectionContent(content) + + return replyInfo + + def __statusParsing(self, content: list, lineNum: int) -> dict: + """Returns a dictionary with status information + + Example: + *** Status updated by: campb303 at: 6/23/2020 13:26:55 ***\n + Dont Delete\n + + Args: + content (list): The content of a status update + lineNum (int): The line number of a status update in an item + + Returns: + dict: a dictionary with these keys, + "type": "status", + "by": initiator of the status update, + "datetime": datetime of the status update, + "content": content of the status update + """ + statusInfo = {} + + statusInfo["type"] = "status" + + delimiterLine = content[0] + + for count, line in enumerate(content): + if line == "===============================================\n": + errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" + return self.__errorParsing(line, lineNum + count + 1, errorMessage) + + # Parses for the author of the status change, which is located between the "*** Status updated by: " and " at:" substrings + try: + statusInfo["by"] = ( + re.search("(?<=\*{3} Status updated by: )(.*)(?= at:)", delimiterLine)).group() + except: + errorMessage = "*** Status updated by: [username] at: [date and time] ***\n" + + return self.__errorParsing(delimiterLine, lineNum, errorMessage) + + # Parses for the date and time of the status change, which is located between the " at: " and "***\n" substrings + try: + dateTimeString = re.search( + "(?<= at: )(.*)(?= \*\*\*\n)", delimiterLine).group() + except: + errorMessage = "*** Status updated by: [username] at: [date and time] ***\n" + + return self.__errorParsing(delimiterLine, lineNum, errorMessage) + + # Formats the date to UTC + statusInfo["datetime"] = self.__getFormattedDate(dateTimeString) + + # Remove the delimiter String and unecessary newlines + statusInfo["content"] = self.__getFormattedSectionContent(content) + + return statusInfo + + def __userReplyParsing(self, replyContent: list, lineNumber: int) -> dict: + """Returns a dictionary with user reply information + + Example: + === Additional information supplied by user ===\n + \n + Subject: Re: Beepboop\n + From: Justin Campbell \n + Date: Tue, 23 Jun 2020 13:30:45 -0400\n + X-ECN-Queue-Original-Path: /home/pier/e/queue/Attachments/inbox/2020-06-23/212-original.txt\n + X-ECN-Queue-Original-URL: https://engineering.purdue.edu/webqueue/Attachments/inbox/2020-06-23/212-original.txt\n + \n + Huzzah!\n + \n + ===============================================\n + \n + Args: + replyContent (list): The entire section of a reply-from-user + lineNumber (int): The line number of the begining of a reply-from-user section within and item + + Returns: + dict: a dictionary with these keys, + "type": "reply_from_user", + "from_name": name of the user that sent the reply, + "from_email": email of the user that sent the reply, + "subject": subject of the reply, + "datetime": the datetime of the reply, + "cc": [ + {"name": name of the carbon copied recipient, + "email": email of the carbon copied recipient + }, + ] + "content": content of the reply + "headers": [ + {"type": headerType, + "content": content + }, + ] + """ + replyFromInfo = {} + + replyFromInfo["type"] = "reply_from_user" + + replyFromHeaders = [] + newLineCounter = 0 + endingDelimiterCount = 0 + + # Delimiter information line numbers to remove from reply from user + linesToRemove = [] + + # Parses the section content looking for any line that starts with a metadata, also tracks the line + # number with the enumerate function + for lineNum, line in enumerate(replyContent): + + if endingDelimiterCount == 0 and lineNum == len(replyContent) - 1: + errorMessage = "Did not encounter a reply-from-user ending delimiter" + return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) + + if newLineCounter == 1 and line != "\n": + + try: + # Append header information for each headr line + headerType, content = line.split(": ", 1) + replyFromHeaders.append( + {"type": headerType, + "content": content + } + ) + except: + lenReplyFromHeaders = len(replyFromHeaders) + if lenReplyFromHeaders == 0: + errorMessage = ("Expected reply-from-user header information:\n" + + "=== Additional information supplied by user ===\n" + + "\n" + + "[Header Type]: [Header Value]\n" + + "\n" + ) + return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) + + else: + replyFromHeaders[lenReplyFromHeaders - + 1]["content"] = replyFromHeaders[lenReplyFromHeaders - 1]["content"] + " " + line + + linesToRemove.append(lineNum) + # Checks for a newline and breaks for loop on second occurance of a newline + if line == "\n": + newLineCounter = newLineCounter + 1 + + elif line == "===============================================\n": + endingDelimiterCount = endingDelimiterCount + 1 + + elif line.startswith("From: ") and newLineCounter == 1: + # Returns a list of one tuples with a name stored in the first index of the tuple and an email stored in the second index of the tuple + emailList = email.utils.getaddresses([line]) + replyFromInfo["from_name"] = emailList[0][0] + replyFromInfo["from_email"] = emailList[0][1] + + elif line.startswith("Subject: ") and newLineCounter == 1: + # Matches everything after "Subject: " + try: + subjectStr = ( + re.search("(?<=Subject: )(.*)", line)).group() + except: + errorMessage = "Expeted syntax of \"Subject: [subject]\"" + return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) + + # Formatts the date to UTC + replyFromInfo["subject"] = subjectStr + + elif line.startswith("Date: ") and newLineCounter == 1: + # Matches everything after "Date: " + try: + dateStr = (re.search("(?<=Date: )(.*)", line)).group() + except: + errorMessage = "\"Date: [datetime]\"" + return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) + + # Formatts the date to UTC + replyFromInfo["datetime"] = self.__getFormattedDate(dateStr) + + elif line.startswith("Cc: ") and newLineCounter == 1: + + replyFromInfo["cc"] = [] + + # Returns a list of tuples with email information + recipientsList = email.utils.getaddresses([line]) + + # Parses through the cc tuple list + for cc in recipientsList: + # Stores the cc information in a dictionary and appends it to the ccRecipientsList + replyFromInfo["cc"].append( + {"name": cc[0], + "email": cc[1]} + ) + + # Deletes reduntant lines from the message content in reverse order + for lineNum in sorted(linesToRemove, reverse=True): + replyContent.pop(lineNum) + + # Strips any unnecessary newlines or any delimiters frm the message content + replyFromInfo["content"] = self.__getFormattedSectionContent( + replyContent) + + replyFromInfo["headers"] = replyFromHeaders + + return replyFromInfo + + def __getFormattedSectionContent(self, sectionContent: list) -> list: + """Returns a list with message content that is stripped of unnecessary newlines and begining delimiters + + Example: + *** Edited by: mph at: 02/21/20 10:27:16 ***\n + \n + Still need to rename machines - but the networking issue now seems to \n + be resolved via another ticket.\n + \n + \n + \n + \n + \n + + Args: + sectionContent (list): The section content of a parsed section + + Returns: + list: the section content of a parsed section without any delimiters and unnecessary newlines + """ + # Continually removes the first line of sectionContent if it is a newline or delimiter in each iteration + while len(sectionContent) > 1: + if (sectionContent[0] == "\n" or + sectionContent[0].startswith("*** Edited by: ") or + sectionContent[0].startswith("*** Replied by: ") or + sectionContent[0].startswith("*** Status updated by: ") or + sectionContent[0] == "=== Additional information supplied by user ===\n" or + sectionContent[0] == "===============================================\n" + ): + sectionContent.pop(0) + else: + # Breaks the loop if the first line isn't a newline or delimiter + break + + # Continually removes the last line of sectionContent if it is a newline or delimiter in each iteration + while len(sectionContent) > 1: + # Initializes the Length of sectionContent each iteration of the loop + sectionContentLength = len(sectionContent) + + if (sectionContent[sectionContentLength - 1] == "\n" or + sectionContent[sectionContentLength - + 1] == "===============================================\n" + ): + sectionContent.pop(sectionContentLength - 1) + else: + # Breaks the loop if the last line isn't a newline or delimiter + break + + return sectionContent + + def __errorParsing(self, line: str, lineNum: int, expectedSyntax: str) -> dict: + """Returns a dictionary with error parse information when a line is malformed + + Example: + "*** Status updated by: ewhile at: 5/7/2020 10:59:11 *** sharing between\n" + + Args: + line (str): line of that threw error + lineNum (int): line number in the item that threw error + expectedSyntax (str): a message stating the syntax the line should follow + + Returns: + dict: a dictionary with these keys, + "type": "parse_error", + "datetime": time the error was encountered, + "file_path": path of the item with erroneos line, + "expected": expectedSyntax, + "got": line, + "line_num": lineNum + """ + errorDictionary = {} + + # Type + errorDictionary["type"] = "parse_error" + + # Dateime of the parse error + errorDictionary["datetime"] = self.__getFormattedDate( + str(datetime.datetime.now())) + + # Item filepath + errorDictionary["file_path"] = self.__path + + # Expected value + errorDictionary["expected"] = expectedSyntax + + # line that threw error + errorDictionary["got"] = line + + # line number that threw error + errorDictionary["line_num"] = lineNum + + # returns the error dictionary + return errorDictionary + + def __getSortedSections(self, sectionsList: list) -> list: + """Sorts the sections chronologically by datetime + + Example: + [example] need to do + + Args: + sections (list): the list of sections to be sorted + + Returns: + list: a list of sections sorted by datetime + """ + sectionsLength = len(sectionsList) + sortedSections = [] + oldestSection = {} + + while len(sortedSections) < sectionsLength: + + for iteration, currentSection in enumerate(sectionsList): + + if currentSection["type"] == "directory_information": + sortedSections.append(currentSection) + sectionsList.remove(currentSection) + break + + if iteration == 0: + oldestSection = currentSection + + #datetime.datetime.strptime(date_time_str, '%Y-%m-%d %H:%M:%S.%f') + + elif parse(currentSection["datetime"]) < parse(oldestSection["datetime"]): + oldestSection = currentSection + + if iteration == len(sectionsList) - 1: + sortedSections.append(oldestSection) + sectionsList.remove(oldestSection) + + return sortedSections + + def __isLocked(self) -> Union[str, bool]: + """Returns a string info about the lock if true and a bool False if false + + Example: A file is locked + "CE 100 is locked by campb303 using qvi" + + Example: a file is not locked + False + + Returns: + Union[str, bool]: String with info about lock if true, bool False if false + """ + lockFile = self.__path + ".lck" + if os.path.exists(lockFile): + with open(lockFile) as file: + lockInfo = file.readline().split(" ") + lockedBy = lockInfo[4] + lockedUsing = lockInfo[1] + return "{queue} {number} is locked by {lockedBy} using {lockedUsing}".format(queue=self.queue, number=self.number, lockedBy=lockedBy, lockedUsing=lockedUsing) + else: + return False + + def __getMostRecentHeaderByType(self, headerType: str) -> str: + """Return the data of most recent header of the given type. + If no header of that type exists, return an empty string. + + Example: Requesting a Status header that does exist + __getMostRecentHeaderByType("Status") + becomes "Waiting for Reply" + + Example: Requesting a Status header that doesn't exist + __getMostRecentHeaderByType("Status") + becomes "" + + Args: + headerType (str): Type of header to return. + Returns: + str: data of most recent header of the given type or empty string. + """ + for header in self.headers: + if header["type"] == headerType: + return header["content"] + return "" + + def __parseFromData(self, data: str) -> str: + """Parse From header and return requested data. + Returns empty string if requested data is unavailable. + + Examples: From data is "From: Campbell, Justin " + __parseFromData(data="userName") returns "Campbell, Justin" + __parseFromData(data="userEmail") returns "campb303@purdue.edu" + + Args: + data (str): The data desired; can be "userName" or "userEmail". + + Returns: + str: userName, userEmail or empty string. + """ + fromHeader = self.__getMostRecentHeaderByType("From") + userName, userEmail = email.utils.parseaddr(fromHeader) + + if data == "userName": + return userName + elif data == "userEmail": + return userEmail + else: + raise ValueError( + "data='" + str(data) + "' is not a valid option. data must be \"userName\" or \"userEmail\".") + + def __getUserAlias(self) -> str: + """Returns user's Career Account alias if present. + If Career Account alias isn't present, returns empty string. + + Example: Email from campb303@purdue.edu + userAlias = "campb303" + + Example: Email from spam@spammer.net + userAlias = "" + + Returns: + str: User's Career Account alias if present or empty string + """ + emailUser, emailDomain = self.userEmail.split("@") + return emailUser if emailDomain.endswith("purdue.edu") else "" + + def __getFormattedDate(self, date: str) -> str: + """Returns the date/time formatted as RFC 8601 YYYY-MM-DDTHH:MM:SS+00:00. + Returns empty string if the string argument passed to the function is not a datetime. + See: https://en.wikipedia.org/wiki/ISO_8601 + + Returns: + str: Properly formatted date/time recieved or empty string. + """ + try: + # This date is never meant to be used. The default attribute is just to set timezone. + parsedDate = parse(date, default=datetime.datetime( + 1970, 1, 1, tzinfo=tz.gettz('EDT'))) + except: + return "" + + parsedDateString = parsedDate.strftime("%Y-%m-%dT%H:%M:%S%z") + + return parsedDateString + + def toJson(self) -> dict: + """Returns a JSON safe representation of the item. + + Returns: + dict: JSON safe representation of the item. + """ + return self.jsonData + + def __repr__(self) -> str: + return self.queue + str(self.number) + +# TODO: Make Queue iterable using __iter__. See: https://thispointer.com/python-how-to-make-a-class-iterable-create-iterator-class-for-it/ class Queue: - # TODO: Add Queue class documentation - - def __init__(self, name: str) -> None: - self.name = name - self.__directory = queueDirectory + "/" + self.name + "/" - self.items = self.__getItems() - - self.jsonData = { - "name": self.name, - "length": len(self) - } - - def __getItems(self) -> list: - """Returns a list of items for this Queue - - Returns: - list: a list of items for this Queue - """ - items = [] - - for item in os.listdir(self.__directory): - itemPath = self.__directory + "/" + item - - isFile = True if os.path.isfile(itemPath) else False - - itemPattern = re.compile("^[0123456789]{1,3}$") - isItem = True if itemPattern.match(item) else False - - if isFile and isItem: - items.append( Item(self.name, item) ) - - return items - - def toJson (self) -> dict: - """Return JSON safe representation of the Queue - - The JSON representation of every item in the Queue is added to the - Queue's JSON data then the Queue's JSON data is returned. - - Returns: - dict: JSON safe representation of the Queue - """ - items = [] - for item in self.items: - items.append(item.toJson()) - self.jsonData["items"] = items - - return self.jsonData - - def __len__(self) -> int: - return len(self.items) - -def getQueues() -> list: - """Return a list of Queues for each queue. - - Returns: - list: list of Queues for each queue. - """ - queues = [] - - for file in os.listdir(queueDirectory): - currentFile = queueDirectory + "/" + file - isDirectory = os.path.isdir(currentFile) - isValid = file not in queuesToIgnore - - if isDirectory and isValid: - queues.append(Queue(file)) - - return queues \ No newline at end of file + """A collection of items. + + Example: + # Create a queue (ce) + >>> queue = Queue("ce") + + Attributes: + name: The name of the queue. + items: A list of Items in the queue. + jsonData: A JSON serializable representation of the Queue. + """ + + def __init__(self, name: str) -> None: + self.name = name + self.__directory = queueDirectory + "/" + self.name + "/" + self.items = self.__getItems() + + self.jsonData = { + "name": self.name, + "length": len(self) + } + + def __getItems(self) -> list: + """Returns a list of items for this Queue + + Returns: + list: a list of items for this Queue + """ + items = [] + + for item in os.listdir(self.__directory): + itemPath = self.__directory + "/" + item + + isFile = True if os.path.isfile(itemPath) else False + + if isFile and isValidItemName(item): + items.append(Item(self.name, item)) + + return items + + def toJson(self) -> dict: + """Return JSON safe representation of the Queue + + The JSON representation of every item in the Queue is added to the + Queue's JSON data then the Queue's JSON data is returned. + + Returns: + dict: JSON safe representation of the Queue + """ + items = [] + for item in self.items: + items.append(item.toJson()) + self.jsonData["items"] = items + + return self.jsonData + + def __len__(self) -> int: + return len(self.items) + + def __repr__(self) -> str: + return f'{self.name}_queue' + +def getValidQueues() -> list: + """Returns a list of queues on the filesystem excluding ignored queues. + + Example: + ["bidc", "me", "ce"] + + Returns: + list: Valid queues + """ + queues = [] + + for file in os.listdir(queueDirectory): + currentFile = queueDirectory + "/" + file + isDirectory = os.path.isdir(currentFile) + isValid = file not in queuesToIgnore + + if isDirectory and isValid: + queues.append(file) + + return queues + +def getQueueCounts() -> list: + """Returns a list of dictionaries with the number of items in each queue. + + Example: + [ + { + name: "me", + number_of_items: 42 + }, + { + name: "bidc", + number_of_items: 3 + } + ] + + Returns: + list: Dictionaries with the number of items in each queue. + """ + queueInfo = [] + for queue in getValidQueues(): + possibleItems = os.listdir(queueDirectory + "/" + queue) + validItems = [isValidItemName for file in possibleItems] + queueInfo.append( {"name": queue, "number_of_items": len(validItems)} ) + return queueInfo + + +def loadQueues() -> list: + """Return a list of Queues for each queue. + + Returns: + list: list of Queues for each queue. + """ + queues = [] + + for queue in getValidQueues(): + queues.append(Queue(queue)) + + return queues diff --git a/api/api.py b/api/api.py index 1e91580..0cbd771 100644 --- a/api/api.py +++ b/api/api.py @@ -1,7 +1,17 @@ -from flask import Flask +from flask import Flask, request, after_this_request from flask_restful import Api, Resource +from flask_jwt_extended import ( + JWTManager, create_access_token, create_refresh_token, + jwt_required, get_jwt_identity, jwt_refresh_token_required, + set_refresh_cookies, unset_refresh_cookies +) +from werkzeug.security import check_password_hash +import os, dotenv import ECNQueue +# Load envrionment variables for ./.env +dotenv.load_dotenv() + # Create Flask App app = Flask(__name__) @@ -9,63 +19,178 @@ api = Api(app) +################################################################################ +# Configure Flask-JWT-Extended +################################################################################ + +# Set JWT secret key and create JWT manager +app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY") +# Set identity claim field key to sub for JWT RFC complience +# Flask-JWT-Extended uses 'identity' by default for compatibility reasons +app.config["JWT_IDENTITY_CLAIM"] = "sub" +# Set the key for error messages generated by Flask-JWT-Extended +app.config["JWT_ERROR_MESSAGE_KEY"] = "message" + +# Look for JWTs in headers (for access) then cookies (for refresh) +app.config["JWT_TOKEN_LOCATION"] = ["headers", "cookies"] +# Restrict cookies to HTTPS in prod, allow HTTP in dev +app.config["JWT_COOKIE_SECURE"] = False if os.environ.get("ENVIRONMENT") == "dev" else True +# Restrict cookies using SameSite=strict flag +app.config["JWT_COOKIE_SAMESITE"] = "strict" +# Restrict refresh tokens to /token/refresh endpoint +app.config["JWT_REFRESH_COOKIE_PATH"] = '/tokens/refresh' +# Set the cookie key for CRSF validation string +# This is the default value. Adding it for easy reference +app.config["JWT_REFRESH_CSRF_HEADER_NAME"] = "X-CSRF-TOKEN" + +tokenManager = JWTManager(app) + + + +class Login(Resource): + def post(self) -> tuple: + """Validates username/password and returns both access and refresh tokens. + + Return Codes: + 200 (OK): On success. + 401 (Unauthroized): When username or password are incorrect. + 422 (Unprocessable Entitiy): When the username or password can't be parsed. + + Example: + curl -X POST + -H "Content-Type: application/json" + -d '{"username": "bob", "password": "super_secret"}' + + { "access_token": fjr09hfp09h932jp9ruj3.3r8ihf8h0w8hr08ifhj804h8i.8h48ith08ity409hip0t4 } + + Returns: + tuple: Response containing tokens and HTTP response code. + """ + if not request.is_json: + return ({ "message": "JSON missing from request body"}, 422) + + data = request.json + + fields_to_check = ["username", "password"] + for field in fields_to_check: + if field not in data.keys(): + return ({ "message": f"{field} missing from request body"}, 422) + + if data["username"] != os.environ.get("SHARED_USERNAME"): + return ({ "message": "Username invalid"}, 401) + if not check_password_hash(os.environ.get("SHARED_PASSWORD_HASH"), data["password"]): + return ({ "message": "Password invalid"}, 401) + + access_token = create_access_token(data["username"]) + refresh_token = create_refresh_token(data["username"]) + + # This decorator is needed because Flask-RESTful's 'resourceful routing` + # doesn't allow for direct modification to the Flask response object. + # See: https://flask-restful.readthedocs.io/en/latest/quickstart.html#resourceful-routing + @after_this_request + def _does_this_work(response): + set_refresh_cookies(response, refresh_token) + return response + + return ({ "access_token": access_token }, 200) + +class RefreshAccessToken(Resource): + @jwt_refresh_token_required + def post(self): + username = get_jwt_identity() + access_token = create_access_token(username) + return ({"access_token": access_token}, 200) class Item(Resource): - def get(self, queue: str, number: int) -> str: - """Returns the JSON representation of the item requested. - - Example: - /api/ce/100 returns: - { - "lastUpdated": "07-23-20 10:11 PM", - "headers": [...], - "content": [...], - "isLocked": "ce 100 is locked by knewell using qvi", - "userEmail": "campb303@purdue.edu", - "userName": "Justin Campbell", - "userAlias": "campb303", - "assignedTo": "campb303", - "subject": "Beepboop", - "status": "Dont Delete", - "priority": "", - "deparment": "", - "building": "", - "dateReceived": "Tue, 23 Jun 2020 13:25:51 -0400" - } - - Args: - queue (str): The queue of the item requested. - item (int): The number of the item requested. - - Returns: - str: JSON representation of the item requested. - """ - return ECNQueue.Item(queue, number).toJson() + @jwt_required + def get(self, queue: str, number: int) -> tuple: + """Returns the JSON representation of the item requested. + + Return Codes: + 200 (OK): On success. + + Example: + /api/ce/100 returns: + { + "lastUpdated": "07-23-20 10:11 PM", + "headers": [...], + "content": [...], + "isLocked": "ce 100 is locked by knewell using qvi", + "userEmail": "campb303@purdue.edu", + "userName": "Justin Campbell", + "userAlias": "campb303", + "assignedTo": "campb303", + "subject": "Beepboop", + "status": "Dont Delete", + "priority": "", + "deparment": "", + "building": "", + "dateReceived": "Tue, 23 Jun 2020 13:25:51 -0400" + } + + Args: + queue (str): The queue of the item requested. + item (int): The number of the item requested. + + Returns: + tuple: Item as JSON and HTTP response code. + """ + return (ECNQueue.Item(queue, number).toJson(), 200) class Queue(Resource): - def get(self, queue: str) -> str: - """Returns the JSON representation of the queue requested. + @jwt_required + def get(self, queues: str) -> tuple: + """Returns the JSON representation of the queue requested. - Args: - queue (str): The queue requested. + Return Codes: + 200 (OK): On success. - Returns: - str: JSON representation of the queue requested. - """ - queues_requested = queue.split("+") + Args: + queues (str): Plus (+) deliminited list of queues. - queues = [] - for queue in queues_requested: - queues.append(ECNQueue.Queue(queue).toJson()) + Returns: + tuple: Queues as JSON and HTTP response code. + """ + queues_requested = queues.split("+") - return queues + queue_list = [] + for queue in queues_requested: + queue_list.append(ECNQueue.Queue(queue).toJson()) + return (queue_list, 200) +class QueueList(Resource): + @jwt_required + def get(self) -> tuple: + """Returns a list of dictionaries with the number of items in each queue. -api.add_resource(Item, "/api//") -api.add_resource(Queue, "/api/") + Return Codes: + 200 (OK): On success. + + Example: + [ + { + name: "me", + number_of_items: 42 + }, + { + name: "bidc", + number_of_items: 3 + } + ] + Returns: + tuple: Queues and item counts as JSON and HTTP response code. + """ + return (ECNQueue.getQueueCounts(), 200) + +api.add_resource(Login, "/login") +api.add_resource(RefreshAccessToken, "/tokens/refresh") +api.add_resource(Item, "/api//") +api.add_resource(Queue, "/api/") +api.add_resource(QueueList, "/api/get_queues") + if __name__ == "__main__": - app.run() + app.run() \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt index 8a009e1..1aaa908 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -3,6 +3,7 @@ astroid==2.4.2 click==7.1.2 Flask==1.1.2 Flask-RESTful==0.3.8 +Flask-JWT-Extended==3.24.1 gunicorn==20.0.4 isort==4.3.21 itsdangerous==1.1.0 @@ -12,6 +13,7 @@ MarkupSafe==1.1.1 mccabe==0.6.1 pylint==2.5.3 python-dateutil==2.8.1 +python-dotenv==0.15.0 pytz==2020.1 six==1.15.0 toml==0.10.1 diff --git a/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png b/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png new file mode 100644 index 0000000..eb6d9e6 Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png differ diff --git a/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png b/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png new file mode 100644 index 0000000..cb06397 Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png differ diff --git a/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png b/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png new file mode 100644 index 0000000..aa66e5d Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png differ diff --git a/docstring-format.mustache b/docstring-format.mustache index 9a318e0..7fc2a20 100644 --- a/docstring-format.mustache +++ b/docstring-format.mustache @@ -4,35 +4,35 @@ {{extendedSummaryPlaceholder}} Example: - [example] + [example] {{#parametersExist}} Args: {{#args}} - {{var}} ({{typePlaceholder}}): {{descriptionPlaceholder}} + {{var}} ({{typePlaceholder}}): {{descriptionPlaceholder}} {{/args}} {{#kwargs}} - {{var}} ({{typePlaceholder}}, optional): {{descriptionPlaceholder}}. Defaults to {{&default}}. + {{var}} ({{typePlaceholder}}, optional): {{descriptionPlaceholder}}. Defaults to {{&default}}. {{/kwargs}} {{/parametersExist}} {{#exceptionsExist}} Raises: {{#exceptions}} - {{type}}: {{descriptionPlaceholder}} + {{type}}: {{descriptionPlaceholder}} {{/exceptions}} {{/exceptionsExist}} {{#returnsExist}} Returns: {{#returns}} - {{typePlaceholder}}: {{descriptionPlaceholder}} + {{typePlaceholder}}: {{descriptionPlaceholder}} {{/returns}} {{/returnsExist}} {{#yieldsExist}} Yields: {{#yields}} - {{typePlaceholder}}: {{descriptionPlaceholder}} + {{typePlaceholder}}: {{descriptionPlaceholder}} {{/yields}} {{/yieldsExist}} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0910693..b918c04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1840,6 +1840,11 @@ "@babel/types": "^7.3.0" } }, + "@types/cookie": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", + "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -1854,6 +1859,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -9040,6 +9054,11 @@ "object.assign": "^4.1.0" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -12065,6 +12084,16 @@ "use-memo-one": "^1.1.1" } }, + "react-cookie": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.0.3.tgz", + "integrity": "sha512-cmi6IpdVgTSvjqssqIEvo779Gfqc4uPGHRrKMEdHcqkmGtPmxolGfsyKj95bhdLEKqMdbX8MLBCwezlnhkHK0g==", + "requires": { + "@types/hoist-non-react-statics": "^3.0.1", + "hoist-non-react-statics": "^3.0.0", + "universal-cookie": "^4.0.0" + } + }, "react-dev-utils": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.2.1.tgz", @@ -15762,6 +15791,15 @@ "unist-util-is": "^3.0.0" } }, + "universal-cookie": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", + "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", + "requires": { + "@types/cookie": "^0.3.3", + "cookie": "^0.4.0" + } + }, "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", diff --git a/package.json b/package.json index 56190c6..8b496df 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "@testing-library/user-event": "^7.2.1", "clsx": "^1.1.1", "history": "^5.0.0", + "jwt-decode": "^3.1.2", "material-table": "^1.63.1", "react": "^16.13.1", + "react-cookie": "^4.0.3", "react-dom": "^16.13.1", "react-relative-time": "0.0.7", + "react-router-dom": "^5.2.0", "react-scripts": "3.4.1", - "react-table": "^7.5.1", - "react-router-dom": "^5.2.0" + "react-table": "^7.5.1" }, "scripts": { "start:frontend": "react-scripts start", @@ -32,6 +34,7 @@ "venv:create": "python3 utils/venv-manager.py create", "venv:delete": "python3 utils/venv-manager.py delete", "venv:reset": "python3 utils/venv-manager.py reset", + "venv:freeze": "cd api/ && venv/bin/pip freeze | grep -v 'pkg-resources' > requirements.txt", "test": "react-scripts test", "eject": "react-scripts eject" }, diff --git a/src/App.js b/src/App.js index 9999793..c410707 100644 --- a/src/App.js +++ b/src/App.js @@ -1,100 +1,26 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { ThemeProvider } from "@material-ui/core/styles"; -import { Box, makeStyles, Paper } from "@material-ui/core"; -import { Route } from "react-router-dom"; -import clsx from "clsx"; import webqueueTheme from "./theme"; -import ItemTableAppBar from "./components/ItemTableAppBar/"; -import ItemTable from "./components/ItemTable/"; -import ItemViewAppBar from "./components/ItemViewAppBar/"; -import ItemView from "./components/ItemView/"; +import { Switch, Route } from "react-router-dom"; +import PrivateRoute from "./components/PrivateRoute/"; +import AppView from "./components/AppView/"; +import LoginForm from "./components/LoginForm/"; function App() { const [darkMode, setDarkMode] = useState(false); - const [activeItem, setActiveItem] = useState({}); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [items, setItems] = useState([]); - - useEffect(() => { - fetch("/api/ce") - .then(res => res.json()) - .then(queue => { - setItems(queue.items) - }) - }, []) const theme = webqueueTheme(darkMode); - const transitionWidth = theme.transitions.create(["width"], { - duration: theme.transitions.duration.enteringScreen, - easing: theme.transitions.easing.easeInOut - }); - const useStyles = makeStyles({ - "leftCol": { - overflow: "auto", - width: "100vw", - height: "100vh", - transition: transitionWidth, - }, - "rightCol": { - overflow: "auto", - width: "0", - height: "100vh", - transition: transitionWidth, - scrollbarWidth: 0, - }, - "rightColShift": { - overflowY: "auto", - width: "100vw", - flexShrink: "0", - transition: transitionWidth - }, - [theme.breakpoints.up("md")]: { - "rightColShift": { - width: "40vw", - } - }, - }); - const classes = useStyles(); - - return ( + return ( - - - - - console.log("Clicked!") }/> - - - - {items.length === 0 ? null : - { - const item = items.find((item) => { - return item.queue === match.params.queue && item.number === Number(match.params.number); - }); - - if (item === undefined) { - return ( - - ); - } - - setActiveItem(item); - - return ( - <> - - - - ); - } - } - /> - } - - + + + + + + + + ); } diff --git a/src/auth/index.js b/src/auth/index.js new file mode 100644 index 0000000..679b398 --- /dev/null +++ b/src/auth/index.js @@ -0,0 +1 @@ +export { login, refresh } from "./utilities"; \ No newline at end of file diff --git a/src/auth/utilities.js b/src/auth/utilities.js new file mode 100644 index 0000000..8800954 --- /dev/null +++ b/src/auth/utilities.js @@ -0,0 +1,59 @@ +/** Utility Functions for webqueue2 API */ + + + +/** + * Returns an access token to be used for authorization. + * @example + * login("janeDoe", "superSecretPassword") + * @param {String} username + * @param {String} password + * @returns {Boolean | String} An access token on success, `false` otherwise. + */ +export async function login(username, password){ + const loginInit = { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ "username": username, "password": password}) + }; + + let loginResponse = await fetch("/login", loginInit); + let data = await loginResponse.json(); + + if (data === null){ + return false; + } + if (!loginResponse.ok){ + console.error(`Login failed. Got code ${loginResponse.status} (${loginResponse.statusText})`); + return false; + } + + return data.access_token || false; +} + +/** + * Refresh current access token. + * @example + * refresh("csrf_refresh_token") + * @param {String} csrf_refresh_token The current CSRF validation string. + * @returns {Boolean | String} An access token on success, `false` otherwise. + */ +export async function refresh(csrf_refresh_token){ + const refreshInit = { + method: "POST", + headers: {'X-CSRF-TOKEN': csrf_refresh_token}, + }; + + let refreshResponse = await fetch("/tokens/refresh", refreshInit); + let data = await refreshResponse.json(); + + if (data === null){ + return false; + } + if (!refreshResponse.ok){ + console.error(`Refresh failed. Got code ${refreshResponse.status} (${refreshResponse.statusText})`); + return false; + } + + return data.access_token || false; +} \ No newline at end of file diff --git a/src/components/AppView/AppView.js b/src/components/AppView/AppView.js new file mode 100644 index 0000000..75c7c60 --- /dev/null +++ b/src/components/AppView/AppView.js @@ -0,0 +1,143 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Box, makeStyles, Paper, useTheme } from "@material-ui/core"; +import { Route } from "react-router-dom"; +import clsx from "clsx"; +import ItemTableAppBar from "../ItemTableAppBar/"; +import ItemTable from "../ItemTable/"; +import ItemViewAppBar from "../ItemViewAppBar/"; +import ItemView from "../ItemView/"; +import QueueSelector from "../QueueSelector/"; +import { useToken } from "../AuthProvider/"; + +export default function AppView({ setDarkMode }){ + const [activeItem, setActiveItem] = useState({}); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [queues, setQueues] = useState([]); + const [items, setItems] = useState([]); + const [selectedQueues, setSelectedQueues] = useState([]); + const [queueSelectorOpen, setQueueSelectorOpen] = useState(false); + + const access_token = useToken(); + + useEffect( _ => { + async function getQueues(){ + if (access_token === null){ + return undefined + } + + if (queueSelectorOpen){ + return undefined + } + + if (selectedQueues.length > 0){ + let queuesToLoad = ""; + + for (let selectedQueue of selectedQueues){ + queuesToLoad += `+${selectedQueue.name}`; + } + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`/api/${queuesToLoad}`, requestOptions); + const queueJson = await apiResponse.json(); + setQueues(queueJson); + } else { + setQueues([]) + } + } + getQueues(); + }, [selectedQueues, access_token, queueSelectorOpen]); + + useEffect( _ => { + let tempItems = []; + for (let queue of queues){ + tempItems = tempItems.concat(queue.items); + } + setItems(tempItems); + }, [queues]); + + const theme = useTheme(); + const transitionWidth = theme.transitions.create(["width"], { + duration: theme.transitions.duration.enteringScreen, + easing: theme.transitions.easing.easeInOut + }); + const useStyles = makeStyles({ + "leftCol": { + overflow: "auto", + width: "100vw", + height: "100vh", + transition: transitionWidth, + }, + "rightCol": { + overflow: "auto", + width: "0", + height: "100vh", + transition: transitionWidth, + scrollbarWidth: 0, + }, + "rightColShift": { + overflowY: "auto", + width: "100vw", + flexShrink: "0", + transition: transitionWidth + }, + [theme.breakpoints.up("md")]: { + "rightColShift": { + width: "40vw", + } + }, + }); + const classes = useStyles(); + + return( + + + + + + + + + + {items.length === 0 ? null : + { + const item = items.find((item) => { + return item.queue === match.params.queue && item.number === Number(match.params.number); + }); + + if (item === undefined) { + return ( + + ); + } + + setActiveItem(item); + + return ( + <> + + + + ); + } + } + /> + } + + + ); +}; + +AppView.propTypes = {}; + +AppView.defaultProps = {}; \ No newline at end of file diff --git a/src/components/AppView/AppView.md b/src/components/AppView/AppView.md new file mode 100644 index 0000000..9d4107e --- /dev/null +++ b/src/components/AppView/AppView.md @@ -0,0 +1,10 @@ +The primary view for webqueue2. + +--- +```jsx +import AppView from "./AppView"; + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/AppView/index.js b/src/components/AppView/index.js new file mode 100644 index 0000000..08cc8fb --- /dev/null +++ b/src/components/AppView/index.js @@ -0,0 +1 @@ +export { default } from "./AppView"; \ No newline at end of file diff --git a/src/components/AuthProvider/AuthProvider.js b/src/components/AuthProvider/AuthProvider.js new file mode 100644 index 0000000..ae0effe --- /dev/null +++ b/src/components/AuthProvider/AuthProvider.js @@ -0,0 +1,78 @@ +import React, { useState, createContext, useContext, useEffect } from "react"; +import { useCookies } from "react-cookie"; +import { refresh } from "../../auth/"; +import decodeJWT from "jwt-decode"; + + + +const LoginContext = createContext(); +const LoginSetterContext = createContext(); +const TokenContext = createContext(); +const TokenSetterContext = createContext(); + +export const useLogin = () => useContext(LoginContext); +export const useLoginSetter = () => useContext(LoginSetterContext); +export const useToken = () => useContext(TokenContext); +export const useTokenSetter = () => useContext(TokenSetterContext); + + + +export default function AuthProvider({ children }) { + const [loggedIn, setLoggedIn] = useState( false ); + const [token, setToken] = useState( null ); + + const [cookies] = useCookies(["csrf_refresh_token"]); + + async function tryRefresh(csrf_refresh_token){ + if (csrf_refresh_token === undefined){ + return false; + } + + const new_access_token = await refresh(csrf_refresh_token); + if (!new_access_token){ + console.error("Failed to refresh access token.") + return false; + } + + setToken(new_access_token); + setLoggedIn(true); + } + + // Attempt to refresh token on page load + useEffect( _ => { + (async () => { + await tryRefresh(cookies.csrf_refresh_token); + })(); + }, [cookies]); + + // Auto update token + useEffect( () => { + if (token === null) { + return undefined; + } + + // 5 second buffer for access token refresh + const refersh_buffer_time = 5000; + const access_token_expiration_claim = decodeJWT(token).exp + const access_token_expiration_time = new Date(0).setUTCSeconds(access_token_expiration_claim); + const miliseconds_to_access_token_expiration = (access_token_expiration_time - Date.now()) + + const timer = setTimeout( async () => { + await tryRefresh(cookies.csrf_refresh_token); + }, miliseconds_to_access_token_expiration - refersh_buffer_time); + + return () => clearTimeout(timer); + }, [token, cookies]); + + return ( + + + + + {children} + + + + + ); +}; \ No newline at end of file diff --git a/src/components/AuthProvider/AuthProvider.md b/src/components/AuthProvider/AuthProvider.md new file mode 100644 index 0000000..aad5bee --- /dev/null +++ b/src/components/AuthProvider/AuthProvider.md @@ -0,0 +1,14 @@ +AuthProvider + +Description + +--- + +```jsx +import AuthProvider from "./AuthProvider"; + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/AuthProvider/index.js b/src/components/AuthProvider/index.js new file mode 100644 index 0000000..165e516 --- /dev/null +++ b/src/components/AuthProvider/index.js @@ -0,0 +1 @@ +export { default, useLogin, useLoginSetter, useToken, useTokenSetter } from "./AuthProvider"; \ No newline at end of file diff --git a/src/components/ItemBodyView/ItemBodyView.js b/src/components/ItemBodyView/ItemBodyView.js index 4e5edce..08a57f9 100644 --- a/src/components/ItemBodyView/ItemBodyView.js +++ b/src/components/ItemBodyView/ItemBodyView.js @@ -13,8 +13,9 @@ export default function ItemBodyView({ item }) { const useStyles = makeStyles(() => ({ "Timeline-root": { - paddingLeft: "0", - paddingRight: "0", + padding: "0", + marginTop: "0", + marginBottom: "0", }, "TimelineContent-root": { paddingRight: "0", diff --git a/src/components/ItemHeaderView/ItemHeaderView.js b/src/components/ItemHeaderView/ItemHeaderView.js new file mode 100644 index 0000000..798a3fb --- /dev/null +++ b/src/components/ItemHeaderView/ItemHeaderView.js @@ -0,0 +1,93 @@ +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { useTable, useFlexLayout, useFilters } from "react-table"; +import { TableContainer, Table, TableHead, TableRow, TableCell, TableBody, TextField, useTheme, makeStyles } from "@material-ui/core"; + +export default function ItemHeaderView({ data }) { + + const theme = useTheme(); + const useStyles = makeStyles({ + HeaderCell_root: { + paddingBottom: theme.spacing(2), + borderBottomWidth: 0 + }, + ContentCell_root: { + wordBreak: "break-word" + }, + bandedRows: { + '&:nth-of-type(even)': { + backgroundColor: theme.palette.type === 'light' ? theme.palette.grey[50] : theme.palette.grey[700], + } + } + }); + const classes = useStyles(); + + const columns = useMemo(() => [ + { Header: 'Type', accessor: 'type', Cell: ({ value }) => {value} , width: 1 }, + { Header: 'Content', accessor: 'content', width: 2 } + ], []); + + const defaultColumn = { + Filter: ({ column: { Header, setFilter } }) => ( + setFilter(event.target.value) } + type="search" + size="small" + variant="outlined" + color="secondary" + fullWidth + /> + ) + } + + const tableInstance = useTable({ columns, data, defaultColumn }, useFlexLayout, useFilters); + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow + } = tableInstance; + + return ( + + + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => ( + + {column.render('Filter')} + + ))} + + ))} + + + {rows.map( (row) => { + prepareRow(row); + return ( + + {row.cells.map( (cell) => ( + + {cell.render("Cell")} + + ))} + + ); + })} + +
+
+ ); +}; + +ItemHeaderView.propTypes = { + /** An array of object containing header type and content. */ + "data": PropTypes.array +}; + +ItemHeaderView.defaultProps = { + "data": [] +} \ No newline at end of file diff --git a/src/components/ItemHeaderView/ItemHeaderView.md b/src/components/ItemHeaderView/ItemHeaderView.md new file mode 100644 index 0000000..e69de29 diff --git a/src/components/ItemHeaderView/index.js b/src/components/ItemHeaderView/index.js new file mode 100644 index 0000000..f47b29b --- /dev/null +++ b/src/components/ItemHeaderView/index.js @@ -0,0 +1 @@ +export { default } from "./ItemHeaderView"; \ No newline at end of file diff --git a/src/components/ItemTable/ItemTable.js b/src/components/ItemTable/ItemTable.js index 695a96f..e45dc63 100644 --- a/src/components/ItemTable/ItemTable.js +++ b/src/components/ItemTable/ItemTable.js @@ -1,14 +1,17 @@ -import React from "react"; +import React, { useState } from "react"; import PropTypes from "prop-types"; import { useTable, useFilters, useFlexLayout, useSortBy } from "react-table"; -import { makeStyles, Table, TableBody, TableCell, TableHead, TableRow, TableContainer, TableSortLabel - , Paper, Grid, useTheme, } from "@material-ui/core"; +import { Table, TableBody, TableCell, TableHead, TableRow, TableContainer, Paper, Grid, ButtonGroup, IconButton, makeStyles, useTheme } from "@material-ui/core"; import { useHistory } from "react-router-dom"; +import RelativeTime from "react-relative-time"; import ItemTableFilter from "../ItemTableFilter/" +import { ArrowDownward, ArrowUpward } from "@material-ui/icons"; -export default function ItemTable({ data, onRowClick }) { - const theme = useTheme(); +export default function ItemTable({ data, rowCanBeSelected }) { + const [selectedRow, setSelectedRow] = useState({ queue: null, number: null}); + + const theme = useTheme(); const useStyles = makeStyles({ // Fully visible for active icons activeSortIcon: { @@ -18,11 +21,19 @@ export default function ItemTable({ data, onRowClick }) { inactiveSortIcon: { opacity: 0.2, }, + rowSelected: { + backgroundColor: theme.palette.type === 'light' ? theme.palette.primary[100] : theme.palette.primary[600], + }, bandedRows: { '&:nth-of-type(even)': { backgroundColor: theme.palette.type === 'light' ? theme.palette.grey[50] : theme.palette.grey[700], }, }, + columnBorders: { + borderLeftWidth: "1px", + borderLeftStyle: "solid", + borderColor: theme.palette.type === "light" ? theme.palette.grey[300] : theme.palette.grey[500] + }, }); const classes = useStyles(); @@ -36,10 +47,10 @@ export default function ItemTable({ data, onRowClick }) { { Header: 'Subject', accessor: 'subject' }, { Header: 'Status', accessor: 'status', }, { Header: 'Priority', accessor: 'priority' }, - { Header: 'Last Updated', accessor: 'lastUpdated' }, + { Header: 'Last Updated', accessor: 'lastUpdated', Cell: ({ value }) => }, { Header: 'Department', accessor: 'department' }, { Header: 'Building', accessor: 'building' }, - { Header: 'Date Received', accessor: 'dateReceived' }, + { Header: 'Date Received', accessor: 'dateReceived', Cell: ({ value }) => }, ], []); const tableInstance = useTable( @@ -55,14 +66,14 @@ export default function ItemTable({ data, onRowClick }) { /> ); } - } + }, }, - useFilters, useFlexLayout, useSortBy + useFilters, useFlexLayout, useSortBy, ); - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = tableInstance; + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, } = tableInstance; return ( - + {headerGroups.map(headerGroup => ( @@ -75,15 +86,32 @@ export default function ItemTable({ data, onRowClick }) { {column.render("Filter")} - - - + + { + const isSortedAsc = column.isSorted && !column.isSortedDesc; + isSortedAsc ? column.clearSortBy() : column.toggleSortBy(false) + })} + > + + + { + const isSortedDesc = column.isSorted && column.isSortedDesc; + isSortedDesc ? column.clearSortBy() : column.toggleSortBy(true) + })} + > + + + @@ -95,12 +123,23 @@ export default function ItemTable({ data, onRowClick }) { {rows.map((row, i) => { prepareRow(row); + let isSelected = selectedRow.queue === row.original.queue && selectedRow.number === row.original.number return ( - history.push(`/${row.original.queue}/${row.original.number}`) } - className={classes.bandedRows} {...row.getRowProps()}> + { + history.push(`/${row.original.queue}/${row.original.number}`); + setSelectedRow({ queue: row.original.queue, number: row.original.number }); + }} + // This functionality should be achieved by using the selected prop and + // overriding the selected class but this applied the secondary color at 0.08% opacity. + // Overridding the root class is a workaround. + classes={{ root: (isSelected && rowCanBeSelected) ? classes.rowSelected : classes.bandedRows }} + {...row.getRowProps()} > {row.cells.map(cell => ( - + {cell.render("Cell")} ))} @@ -115,9 +154,14 @@ export default function ItemTable({ data, onRowClick }) { ItemTable.propTypes = { /** Array of items from all active queues to display in table. */ - "items": PropTypes.array + "items": PropTypes.array, + /** State variable indicating if rows can be selected. When false, all rows are deselected. */ + "rowCanBeSelected": PropTypes.bool }; ItemTable.defaultProps = { - "items": [] -}; \ No newline at end of file + /** The items to display in the table. */ + "items": [], + /** A state variable determining whether a row can be selected or not. */ + "rowCanBeSelected": true +}; diff --git a/src/components/ItemTableFilter/ItemTableFilter.js b/src/components/ItemTableFilter/ItemTableFilter.js index 68f9d56..b5a2fae 100644 --- a/src/components/ItemTableFilter/ItemTableFilter.js +++ b/src/components/ItemTableFilter/ItemTableFilter.js @@ -1,21 +1,33 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { TextField } from "@material-ui/core"; +import { makeStyles, TextField } from "@material-ui/core"; export default function ItemTableFilter({ label, onChange }) { + const useStyles = makeStyles({ + labelRoot: { + overflow: "hidden" + }, + labelFocused: { + overflow: "visible" + }, + }); + const classes = useStyles(); + + const [isFocused, setIsFocused] = useState(false); + return ( - <> setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className={ isFocused ? classes.labelFocused : classes.labelRoot } color="secondary" type="search" size="small" variant="outlined" - fullWidth /> - ); }; diff --git a/src/components/ItemView/ItemView.js b/src/components/ItemView/ItemView.js index 5ec157a..8bd7eda 100644 --- a/src/components/ItemView/ItemView.js +++ b/src/components/ItemView/ItemView.js @@ -1,10 +1,15 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from "prop-types"; -import { Paper, makeStyles, useTheme } from '@material-ui/core'; +import { Paper, AppBar, Tab, makeStyles, useTheme } from '@material-ui/core'; +// Import these tab components from @material-ui/lab instead of @material-ui/core for automatic a11y props +// See: https://material-ui.com/components/tabs/#experimental-api +import { TabContext, TabList, TabPanel } from '@material-ui/lab'; import ItemMetadataView from "../ItemMetadataView/" import ItemBodyView from "../ItemBodyView"; +import ItemHeaderView from "../ItemHeaderView"; export default function ItemView({ activeItem }){ + const [activeTab, setActiveTab] = useState('Conversation'); const theme = useTheme(); const useStyles = makeStyles({ @@ -12,15 +17,35 @@ export default function ItemView({ activeItem }){ paddingTop: theme.spacing(1), paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), - border: "none" + border: "none", + }, + "tabPanelPadding": { + padding: `${theme.spacing(2)}px ${theme.spacing(2)}px` } }); const classes = useStyles(); -return( + const handleTabChange = (event, newValue) => { + setActiveTab(newValue); + }; + + return( - + + + + + + + + + + + + + + ); }; diff --git a/src/components/ItemViewAppBar/ItemViewAppBar.js b/src/components/ItemViewAppBar/ItemViewAppBar.js index a517cb3..0dc081f 100644 --- a/src/components/ItemViewAppBar/ItemViewAppBar.js +++ b/src/components/ItemViewAppBar/ItemViewAppBar.js @@ -22,11 +22,8 @@ export default function ItemViewAppBar({ title, setSidebarOpen }){ }, appBarRoot: { width: "inherit", - position: "inherit" + }, - paddingToolbar: { - position: "absolute" - } })); const classes = useStyles(theme); @@ -55,7 +52,7 @@ export default function ItemViewAppBar({ title, setSidebarOpen }){ - + ); } diff --git a/src/components/LoginForm/LoginForm.js b/src/components/LoginForm/LoginForm.js new file mode 100644 index 0000000..5eb2392 --- /dev/null +++ b/src/components/LoginForm/LoginForm.js @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { Box, Paper, TextField, Button, Avatar, Typography, InputAdornment, IconButton, useTheme, makeStyles } from "@material-ui/core"; +import Visibility from '@material-ui/icons/Visibility'; +import VisibilityOff from '@material-ui/icons/VisibilityOff'; +import { Redirect } from "react-router-dom"; +import { Alert } from '@material-ui/lab'; +import { useLogin, useLoginSetter, useTokenSetter } from "../AuthProvider/"; +import { login } from "../../auth/"; + +export default function LoginForm() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const handleUsernameChange = (event) => setUsername(event.target.value); + const handlePasswordChange = (event) => setPassword(event.target.value); + + const setLogin = useLoginSetter(); + const setToken = useTokenSetter(); + const handleSubmit = async (event) => { + event.preventDefault(); + let access_token = await login(username, password); + + if (!access_token){ + setError(true); + return false; + } + + setLogin(true); + setToken(access_token); + return true; + } + + const theme = useTheme(); + const useStyles = makeStyles({ + "box_root": { + background: `linear-gradient(120deg, ${theme.palette.secondary.main}35 0%, ${theme.palette.primary.main}15 100%)`, + width: "100%", + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center" + }, + "avatar_root": { + padding: theme.spacing(1), + width: theme.spacing(10), + height: theme.spacing(10) + }, + "alert_root": { + marginTop: theme.spacing(2) + }, + "paper_root": { + minWidth: theme.breakpoints.values.sm/2, + padding: theme.spacing(3), + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + }, + "button_root": { + marginTop: theme.spacing(2) + } + }) + const classes = useStyles(); + + const isLoggedIn = useLogin(); + if (isLoggedIn) { + return + } + + const LoginErrorAlert = _ => { + return ( + + Username or password is incorrect. + + ); + } + + const ViewPasswordToggle = _ => { + return ( + + setShowPassword(!showPassword) } + onMouseDown={ (event) => event.preventDefault() } + > + { showPassword ? : } + + + ); + } + + return ( + +
+ + + + Sign In + + { error && } + + + }} + /> + + + +
+ ); +}; \ No newline at end of file diff --git a/src/components/LoginForm/LoginForm.md b/src/components/LoginForm/LoginForm.md new file mode 100644 index 0000000..7e0553c --- /dev/null +++ b/src/components/LoginForm/LoginForm.md @@ -0,0 +1,11 @@ +The LoginForm acts as the only public facing page for the webqueue2. If any part of the app is access without access tokens, the user will be redirected here. It takes a username and password, attempts to login an, if successful, sets access tokens and redirects users to webqueue2. + +--- +```jsx +import LoginForm from "./LoginForm"; + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/LoginForm/index.js b/src/components/LoginForm/index.js new file mode 100644 index 0000000..3789b92 --- /dev/null +++ b/src/components/LoginForm/index.js @@ -0,0 +1 @@ +export { default } from "./LoginForm"; \ No newline at end of file diff --git a/src/components/PrivateRoute/PrivateRoute.js b/src/components/PrivateRoute/PrivateRoute.js new file mode 100644 index 0000000..9a7ebad --- /dev/null +++ b/src/components/PrivateRoute/PrivateRoute.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from "prop-types"; +import { Route, Redirect } from 'react-router-dom'; +import { useLogin } from "../AuthProvider/"; + +export default function PrivateRoute({ children, ...rest }) { + const isLoggedIn = useLogin(); + + return ( + + { + isLoggedIn + ? children + : + } + + ); +}; + +PrivateRoute.propTypes = { + /** The route's path. */ + "path": PropTypes.string.isRequired +}; \ No newline at end of file diff --git a/src/components/PrivateRoute/PrivateRoute.md b/src/components/PrivateRoute/PrivateRoute.md new file mode 100644 index 0000000..7f0d5c0 --- /dev/null +++ b/src/components/PrivateRoute/PrivateRoute.md @@ -0,0 +1 @@ +The PrivateRoute wraps [React Router](https://reactrouter.com/)'s [Route component](https://reactrouter.com/web/api/Route) and checks for authentication using [AuthProvider](#/Components/AuthProvider). If authentication is valid, the children of the PrivateRoute are rendered. Otherwise, the user is redirected to the login page. \ No newline at end of file diff --git a/src/components/PrivateRoute/index.js b/src/components/PrivateRoute/index.js new file mode 100644 index 0000000..4c9765d --- /dev/null +++ b/src/components/PrivateRoute/index.js @@ -0,0 +1 @@ +export { default } from "./PrivateRoute"; \ No newline at end of file diff --git a/src/components/QueueSelector/QueueSelector.js b/src/components/QueueSelector/QueueSelector.js new file mode 100644 index 0000000..aa1a816 --- /dev/null +++ b/src/components/QueueSelector/QueueSelector.js @@ -0,0 +1,176 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { TextField, Checkbox, InputAdornment, Box, useTheme } from "@material-ui/core"; +import { Autocomplete } from "@material-ui/lab"; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import { useCookies } from "react-cookie"; +import { useToken } from "../AuthProvider/"; + +/** + * Get queue names and number of items. + * @param {String} access_token A valid API access token. + * @returns Array of objects containing queue names and item counts. + */ +const getQueueCounts = async (access_token) => { + if (access_token === null){ + return undefined + } + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`/api/get_queues`, requestOptions); + const queueCountJson = await apiResponse.json(); + + return queueCountJson; +}; + +export default function QueueSelector({ open, setOpen, value, setValue }) { + const [queueCounts, setQueueCounts] = useState([]); + const [isFirstRender, setIsFirstRender] = useState(true); + const access_token = useToken(); + const loading = open && queueCounts.length === 0; + + const [cookies, setCookie] = useCookies(["active-queues"]); + const activeQueues = cookies['active-queues'] !== undefined ? cookies['active-queues'].split(',') : []; + + const theme = useTheme(); + + // Prepopulate Active Queues from Cookies + useEffect( _ => { + if (access_token === null){ + return undefined; + } + + if (isFirstRender) { + ( async _ => { + // Get queue counts + let queueCountsJson = await getQueueCounts(access_token); + + // Find queue count info for queue names in active queues + let activeQueuesInfo = activeQueues.map((queueName) => ( + queueCountsJson.find( ({ name }) => queueName === name ) + )); + + // Filter undefined values + activeQueuesInfo = activeQueuesInfo.filter( (entry) => entry !== undefined); + + setValue(activeQueuesInfo); + setIsFirstRender(false); + })(); + } + }, []); + + // Get queue counts if QueueSelector is open + useEffect( _ => { + (async _ => { + if (loading) { + let queueCountsJson = await getQueueCounts(access_token); + setQueueCounts(queueCountsJson); + } + })() + }, [loading, access_token]); + + // Delete queue counts if QueueSelector is closed + useEffect(() => { + if (!open) { + setQueueCounts([]); + } + }, [open]); + + const handleChange = (event, newValue) => { + setValue(newValue) + + // Set active-queues cookie to csv of selected queue names + const activeQueueOptions = { + path: "/", + expires: (_ => { + let expiration_date = new Date(); + expiration_date.setDate(expiration_date.getDate() + 365); + return expiration_date; + })() + }; + const activeQueues = newValue.map( (value) => value.name).join(','); + setCookie("active-queues", activeQueues, activeQueueOptions); + }; + + // Function to render checkboxes in dropdown + // See `renderOptions` prop at https://material-ui.com/api/autocomplete/#props + const optionRenderer = (option, { selected }) => ( + <> + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + {`${option.name} (${option.number_of_items})`} + + ); + + return( + // Box is used for margin because Autocomplete CSS overrides don't work as expected. + + ( + + + Active Queues: + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + <> + {loading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + options={queueCounts} + value={value} + onChange={handleChange} + getOptionLabel={(option) => `${option.name} (${option.number_of_items})`} + renderOption={optionRenderer} + getOptionSelected={ (option, value) => option.name === value.name } + size="small" + open={open} + onOpen={_ => setOpen(true)} + onClose={_ => setOpen(false)} + loading={true} + disableCloseOnSelect + disableListWrap + fullWidth + multiple + autoHighlight + /> + + ); +}; + +QueueSelector.propTypes = { + /** State variable to manage open status. */ + "open": PropTypes.bool.isRequired, + /** Function to update state variable that manages open status. */ + "setOpen": PropTypes.func.isRequired, + /** State variable to manage selected queues. */ + "value": PropTypes.array.isRequired, + /** Function to update state variable that manages selected queues. */ + "setValue": PropTypes.func.isRequired, +}; \ No newline at end of file diff --git a/src/components/QueueSelector/QueueSelector.md b/src/components/QueueSelector/QueueSelector.md new file mode 100644 index 0000000..ea505eb --- /dev/null +++ b/src/components/QueueSelector/QueueSelector.md @@ -0,0 +1,38 @@ +Allows the selection, removal and viewing of active queues. Its extends the [MUI Autocomplete component](https://material-ui.com/components/autocomplete/). + +--- +```jsx +import React, { useState } from "react"; +import { Paper, makeStyles } from "@material-ui/core"; +import QueueSelector from "./QueueSelector"; + +const [selectedQueues, setSelectedQueues] = useState([]); +const queues = [ + { + 'name': 'bidc', + 'number_of_items': 5 + }, + { + 'name': 'epics', + 'number_of_items': 6 + }, + { + 'name': 'wang', + 'number_of_items': 13 + } +]; + +const useStyles = makeStyles({ + root: { + padding: "16px", + } +}); +const classes = useStyles(); + + + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/QueueSelector/index.js b/src/components/QueueSelector/index.js new file mode 100644 index 0000000..4686319 --- /dev/null +++ b/src/components/QueueSelector/index.js @@ -0,0 +1 @@ +export { default } from "./QueueSelector"; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 578b96b..799ad12 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,8 @@ import * as serviceWorker from './serviceWorker'; import { createBrowserHistory } from 'history'; import { CssBaseline } from '@material-ui/core'; import { BrowserRouter as Router } from 'react-router-dom'; +import { CookiesProvider } from "react-cookie"; +import AuthProvider from "./components/AuthProvider/"; export const history = createBrowserHistory({ basename: process.env.PUBLIC_URL @@ -13,9 +15,13 @@ export const history = createBrowserHistory({ ReactDOM.render( - - - + + + + + + + , document.getElementById('root') );