diff --git a/api/ECNQueue.py b/api/ECNQueue.py index a80dce7..f5e7c47 100644 --- a/api/ECNQueue.py +++ b/api/ECNQueue.py @@ -1,10 +1,39 @@ -# 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 from dateutil.parser import parse +from dateutil import tz from typing import Union import json @@ -21,11 +50,7 @@ queueDirectory = os.path.join(currentFileDirectoryParent, "q-snapshot") # Queues to not load in getQueues() -queuesToIgnore = ["archives", "drafts", "inbox"] - -# B/c some items don't have a From field -# See coral259 -queuesToIgnore.append("coral") +queuesToIgnore = ["archives", "drafts", "inbox", "coral"] @@ -33,16 +58,35 @@ # Classes #------------------------------------------------------------------------------# class Item: - # TODO: Add Item class documentation + """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.number = number + raise ValueError(" Could not convert \"" + number + "\" to an integer") self.__path = "/".join([queueDirectory, self.queue, str(self.number)]) self.lastUpdated = self.__getLastUpdated() @@ -53,7 +97,7 @@ def __init__(self, queue: str, number: int) -> None: self.userEmail = self.__parseFromData(data="userEmail") self.userName = self.__parseFromData(data="userName") self.userAlias = self.__getUserAlias() - self.assignedTo = self.__getAssignedTo() + self.assignedTo = self.__getMostRecentHeaderByType("Assigned-To") self.subject = self.__getMostRecentHeaderByType("Subject") self.status = self.__getMostRecentHeaderByType("Status") self.priority = self.__getMostRecentHeaderByType("Priority") @@ -61,6 +105,7 @@ def __init__(self, queue: str, number: int) -> None: 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, @@ -89,9 +134,10 @@ def __getLastUpdated(self) -> str: 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 formattedTime + return self.__getFormattedDate(formattedTime) def __getRawItem(self) -> list: """Returns a list of all lines in the item file @@ -149,7 +195,8 @@ def __parseHeaders(self) -> list: headerString += line - message = email.message_from_string(headerString + "".join(self.__getContent())) + # message = email.message_from_string(headerString + "".join(self.__getContent())) + message = email.message_from_string(headerString) headers = [] for key in message.keys(): @@ -159,26 +206,6 @@ def __parseHeaders(self) -> list: # 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 = [] @@ -228,7 +255,12 @@ def __parseSections(self) -> list: line = self.__rawItem[lineNumber] # Looks for a starting delimiter and explicity excludes the reply-from-user ending delimiter - if line.startswith("***") or line.startswith("===") and not line.startswith("===="): + 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: @@ -278,7 +310,7 @@ def __parseSections(self) -> list: # 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 sections + return self.__getSortedSections(sections) # Appends the edit dictionary to sections sections.append(editInfo) @@ -290,7 +322,7 @@ def __parseSections(self) -> list: # 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 sections + return self.__getSortedSections(sections) # Appends the reply-to to sections sections.append(replyToInfo) @@ -301,7 +333,7 @@ def __parseSections(self) -> list: if statusInfo["type"] == "parse_error": sections.append(statusInfo) - return sections + return self.__getSortedSections(sections) # Appends the status to sections sections.append(statusInfo) @@ -312,12 +344,15 @@ def __parseSections(self) -> list: if replyFromInfo["type"] == "parse_error": sections.append(replyFromInfo) - return sections + return self.__getSortedSections(sections) # Appends the replyFrom to sections sections.append(replyFromInfo) - - return sections + + sortedSections = self.__getSortedSections(sections) + + return sortedSections + #return sections def __directoryParsing(self, directoryStartLine: int) -> dict: """Returns a dictionary with directory information @@ -585,7 +620,7 @@ def __editParsing(self, content: list, lineNum: int) -> dict: editInfo = {} for count, line in enumerate(content): - if line.startswith("===="): + if line == "===============================================\n" : errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" return self.__errorParsing(line, lineNum + count + 1, errorMessage) @@ -645,7 +680,7 @@ def __replyToParsing(self, content: list, lineNum: int) -> dict: delimiterLine = content[0] for count, line in enumerate(content): - if line.startswith("===="): + if line == "===============================================\n": errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" return self.__errorParsing(line, lineNum + count + 1, errorMessage) @@ -695,7 +730,7 @@ def __statusParsing(self, content: list, lineNum: int) -> dict: delimiterLine = content[0] for count, line in enumerate(content): - if line.startswith("===="): + if line == "===============================================\n": errorMessage = "Reply-from-user ending delimter encountered without Reply-from-user starting delimter" return self.__errorParsing(line, lineNum + count + 1, errorMessage) @@ -793,15 +828,24 @@ def __userReplyParsing(self, replyContent: list, lineNumber: int) -> dict: ) except: lenReplyFromHeaders = len(replyFromHeaders) - - replyFromHeaders[lenReplyFromHeaders - 1]["content"] = replyFromHeaders[lenReplyFromHeaders - 1]["content"] + " " + line + 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.startswith("===="): + elif line == "===============================================\n": endingDelimiterCount = endingDelimiterCount + 1 elif line.startswith("From: ") and newLineCounter == 1: @@ -880,7 +924,13 @@ def __getFormattedSectionContent(self, sectionContent: list) -> list: """ # 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("***") or sectionContent[0].startswith("===") : + 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 @@ -891,7 +941,9 @@ def __getFormattedSectionContent(self, sectionContent: list) -> list: # Initializes the Length of sectionContent each iteration of the loop sectionContentLength = len(sectionContent) - if sectionContent[sectionContentLength -1] == "\n" or sectionContent[sectionContentLength -1].startswith("===="): + 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 @@ -942,6 +994,45 @@ def __errorParsing(self, line: str, lineNum: int, expectedSyntax: str) -> dict: # 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 @@ -1022,16 +1113,6 @@ def __getUserAlias(self) -> str: """ 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. @@ -1042,7 +1123,8 @@ def __getFormattedDate(self, date: str) -> str: str: Properly formatted date/time recieved or empty string. """ try: - parsedDate = parse(date) + # This date is never meant to be used. The default attribute is just to set timezone. + parsedDate = parse(date, default=datetime.datetime(1970, 0, 1, tzinfo=tz.gettz('EDT'))) except: return "" @@ -1061,8 +1143,19 @@ def toJson(self) -> dict: 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 + """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 diff --git a/api/api.py b/api/api.py index 29a4492..1e91580 100644 --- a/api/api.py +++ b/api/api.py @@ -52,7 +52,13 @@ def get(self, queue: str) -> str: Returns: str: JSON representation of the queue requested. """ - return ECNQueue.Queue(queue).toJson() + queues_requested = queue.split("+") + + queues = [] + for queue in queues_requested: + queues.append(ECNQueue.Queue(queue).toJson()) + + return queues @@ -62,4 +68,4 @@ def get(self, queue: str) -> str: if __name__ == "__main__": - app.run() \ No newline at end of file + app.run() diff --git a/src/App.js b/src/App.js index 9999793..8320c96 100644 --- a/src/App.js +++ b/src/App.js @@ -13,15 +13,25 @@ function App() { const [darkMode, setDarkMode] = useState(false); const [activeItem, setActiveItem] = useState({}); const [sidebarOpen, setSidebarOpen] = useState(false); + const [queues, setQueues] = useState([]); const [items, setItems] = useState([]); - useEffect(() => { - fetch("/api/ce") - .then(res => res.json()) - .then(queue => { - setItems(queue.items) - }) - }, []) + useEffect( _ => { + async function getQueues(){ + const apiResponse = await fetch("/api/ce"); + const queueJson = await apiResponse.json(); + setQueues(queueJson); + } + getQueues(); + }, []); + + useEffect( _ => { + let tempItems = []; + for (let queue of queues){ + tempItems = tempItems.concat(queue.items); + } + setItems(tempItems); + }, [queues]); const theme = webqueueTheme(darkMode); const transitionWidth = theme.transitions.create(["width"], { diff --git a/src/components/ItemBodyView/ItemBodyView.js b/src/components/ItemBodyView/ItemBodyView.js index 19e48fb..4e5edce 100644 --- a/src/components/ItemBodyView/ItemBodyView.js +++ b/src/components/ItemBodyView/ItemBodyView.js @@ -1,11 +1,12 @@ import React from "react"; import PropTypes from "prop-types"; import { Timeline, TimelineItem, TimelineSeparator, TimelineConnector, TimelineContent, TimelineDot } from '@material-ui/lab'; -import { Typography, makeStyles } from "@material-ui/core"; +import { makeStyles } from "@material-ui/core"; import DirectoryInformation from "../DirectoryInformation/"; import Assignment from "../Assignment/"; import TimelineActionCard from "../TimelineActionCard/"; import MessageView from "../MessageView/"; +import ParseError from "../ParseError/"; import { objectIsEmpty } from "../../utilities"; export default function ItemBodyView({ item }) { @@ -31,42 +32,23 @@ export default function ItemBodyView({ item }) { const generateTimelineItem = (section) => { switch (section.type) { case "directory_information": - return ( - - ); + return case "initial_message": - return ( - <> - - - ); + return case "edit": - return ( - - ); + return case "status": - return ( - <> - - {`${section.by} update the status to at ${Date(section.datetime)}`} - - {section.content.map((line) => {line})} - - ); + return case "assignment": - return ( - - ); + return case "reply_to_user": - return ( - - ); + return case "reply_from_user": - return ( - - ); + return + case "parse_error": + return default: - return "No Match Found"; + return `No match found for type: ${section.type}`; }; }; diff --git a/src/components/ItemTable/ItemTable.js b/src/components/ItemTable/ItemTable.js index 5e53c90..2c5ba42 100644 --- a/src/components/ItemTable/ItemTable.js +++ b/src/components/ItemTable/ItemTable.js @@ -1,8 +1,11 @@ import React from "react"; import PropTypes from "prop-types"; + import { useTable, useFilters, useFlexLayout, useSortBy, useRowSelect } from "react-table"; import { makeStyles, Table, TableBody, TableCell, TableHead, TableRow, TableContainer, Paper, Grid, useTheme, ButtonGroup, IconButton } 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"; @@ -28,6 +31,11 @@ export default function ItemTable({ data }) { 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(); @@ -41,10 +49,11 @@ export default function ItemTable({ data }) { { 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( @@ -80,6 +89,7 @@ export default function ItemTable({ data }) { {column.render("Filter")} + - + @@ -122,11 +132,10 @@ export default function ItemTable({ data }) { prepareRow(row); return ( {history.push(`/${row.original.queue}/${row.original.number}`);}} - className={classes.bandedRows} - {...row.getRowProps()} > + onClick={() => history.push(`/${row.original.queue}/${row.original.number}`)} + className={classes.bandedRows} {...row.getRowProps()}> {row.cells.map(cell => ( - + {cell.render("Cell")} ))} 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/ParseError/ParseError.js b/src/components/ParseError/ParseError.js new file mode 100644 index 0000000..28dd626 --- /dev/null +++ b/src/components/ParseError/ParseError.js @@ -0,0 +1,61 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Typography, Paper, makeStyles, useTheme } from '@material-ui/core'; +import clsx from "clsx"; + +export default function ParseError({ file_path, expected, got, line_num }){ + + const theme = useTheme(); + + const useStyles = makeStyles({ + "Paper-root": { + overflow: "hidden" + }, + "headerColor": { + backgroundColor: theme.palette.parse_error.main + }, + "padding": { + padding: theme.spacing(1) + } + + }); + const classes = useStyles(); + + return( + +
+ + Parsing Error + +
+
+ + File Path: {file_path} + + + Line: {line_num} + + + Expected: {expected} + + + Got: {got} + +
+
+ ); +} + +ParseError.propTypes = { + "file_path": PropTypes.string, + "expected": PropTypes.string, + "got": PropTypes.string, + "line_num": PropTypes.number +}; + +ParseError.defaultProps = { + "file_path": "", + "expected": "", + "got": "", + "line_num": "" +}; \ No newline at end of file diff --git a/src/components/ParseError/ParseError.md b/src/components/ParseError/ParseError.md new file mode 100644 index 0000000..98b58a2 --- /dev/null +++ b/src/components/ParseError/ParseError.md @@ -0,0 +1,36 @@ +Displays a parsing error. + +--- + +```jsx +import { ThemeProvider } from "@material-ui/core/styles"; +import webqueue2Theme from "../../theme"; +import ParseError from "./ParseError"; + +const theme = webqueue2Theme(false); + +const demo_data = { + "type": "parse_error", + "datetime": "2020-10-23T00:45:32", + "file_path": "/home/pier/e/campb303/webqueue2/q-snapshot/ce/32", + "expected": "Did not encounter a reply-from-user ending delimiter", + "got": "765-869-4032 to assist please?\tThank you\n", + "line_num": 120 +}; + + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/ParseError/index.js b/src/components/ParseError/index.js new file mode 100644 index 0000000..8a5ec40 --- /dev/null +++ b/src/components/ParseError/index.js @@ -0,0 +1 @@ +export { default } from "./ParseError"; \ No newline at end of file diff --git a/src/components/TimelineActionCard/TimelineActionCard.js b/src/components/TimelineActionCard/TimelineActionCard.js index 431cd3e..1137b19 100644 --- a/src/components/TimelineActionCard/TimelineActionCard.js +++ b/src/components/TimelineActionCard/TimelineActionCard.js @@ -16,6 +16,10 @@ export default function TimelineActionCard({ type, datetime, by, content }){ "reply_to_user": { "verbage": "replied", "coloring": theme.palette.reply_to_user.main + }, + "status": { + "verbage": "updated the status", + "coloring": theme.palette.status.main } } @@ -54,9 +58,9 @@ TimelineActionCard.propTypes = { ]), /** ISO 8601 formatted time string. */ "datetime": PropTypes.string.isRequired, - /** The name of the person who added the edit. */ + /** The name of the person who added the action. */ "by": PropTypes.string.isRequired, - /** An array of strings containing the content of the edit. */ + /** An array of strings containing the content of the action. */ "content": PropTypes.array.isRequired }; diff --git a/src/theme.js b/src/theme.js index 685626a..52c59bd 100644 --- a/src/theme.js +++ b/src/theme.js @@ -26,8 +26,11 @@ export default function theme(darkMode = false) { "reply_to_user": { main: "rgba(99, 125, 255, 0.2)", }, - "reply_from_user": { + "status": { main: "rgba(99, 255, 151, 0.2)", + }, + "parse_error": { + main: "rgba(255, 99, 204, 0.2)", } }, })