diff --git a/.gitignore b/.gitignore index e238c91..4151e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ yarn-error.log* /api/venv __pycache__/ venv-manager.log -/api/.env \ No newline at end of file +/api/.env +*.egg* diff --git a/api/ECNQueue.py b/api/ECNQueue.py index 2cfcf98..f5888a1 100644 --- a/api/ECNQueue.py +++ b/api/ECNQueue.py @@ -22,10 +22,6 @@ 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] """ #------------------------------------------------------------------------------# @@ -62,7 +58,10 @@ #------------------------------------------------------------------------------# def isValidItemName(name: str) -> bool: - """Returns true if file name is a valid item name + """Returns true if file name is a valid item name. + + A file name is true if it contains between 1 and 3 integer numbers allowing for + any integer between 0 and 999. Example: isValidItemName("21") -> true @@ -83,16 +82,23 @@ def isValidItemName(name: str) -> bool: # Classes #------------------------------------------------------------------------------# class Item: - """A single issue. + """A chronological representation of an interaction with a user. Example: # Create an Item (ce100) - >>> item = Item("ce", 100) + >>> item = Item("ce", 100, headersOnly=false) + # Create an Item without parsing its contents (ce100) + >>> item = Item("ce", 100, headersOnly=True) + + Args: + queue (str): The name of the Item's queue. + number (int): The number of the Item. + headersOnly (bool, optional): Whether or not to parse headers only. Defaults to True. 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. + content: A list of section dictionaries (only included if headersOnly is False). 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. @@ -104,21 +110,22 @@ class Item: department: The most recent department for this item. dateReceived: The date this item was created. jsonData: A JSON serializable representation of the Item. + + Raises: + ValueError: When the number passed to the constructor cannot be parsed. """ - def __init__(self, queue: str, number: int) -> None: + def __init__(self, queue: str, number: int, headersOnly: bool = False) -> None: self.queue = queue try: self.number = int(number) except ValueError: - raise ValueError(" Could not convert \"" + - number + "\" to an integer") - + raise ValueError(f'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() + if not headersOnly: self.content = self.__parseSections() self.isLocked = self.__isLocked() self.userEmail = self.__parseFromData(data="userEmail") self.userName = self.__parseFromData(data="userName") @@ -129,28 +136,12 @@ def __init__(self, queue: str, number: int) -> None: self.priority = self.__getMostRecentHeaderByType("Priority") self.department = self.__getMostRecentHeaderByType("Department") self.building = self.__getMostRecentHeaderByType("Building") - self.dateReceived = self.__getFormattedDate( - self.__getMostRecentHeaderByType("Date")) + self.dateReceived = self.__getFormattedDate(self.__getMostRecentHeaderByType("Date")) + self.jsonData = {} - # 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 - } + for attribute in self.__dir__(): + if "_" not in attribute and attribute != "toJson" and attribute != "jsonData": + self.jsonData[attribute] = self.__getattribute__(attribute) def __getLastUpdated(self) -> str: """Returns last modified time of item reported by the filesystem in mm-dd-yy hh:mm am/pm format. @@ -212,7 +203,7 @@ def __parseHeaders(self) -> list: # Example: # [ce] QTime-Updated-By: campb303 becomes # QTime-Updated-By: campb303 - queuePrefixPattern = re.compile("\[.*\] {1}") + queuePrefixPattern = re.compile(r"\[.*?\] {1}") for lineNumber in range(self.__getHeaderBoundary()): line = self.__rawItem[lineNumber] lineHasQueuePrefix = queuePrefixPattern.match(line) @@ -263,6 +254,12 @@ def __parseSections(self) -> list: for assignment in assignementLsit: sections.append(assignment) + # Checks for empty content within an item and returns and + if contentEnd <= contentStart: + blankInitialMessage = self.__initialMessageParsing([""]) + sections.append(blankInitialMessage) + return sections + # Checks for Directory Identifiers if self.__rawItem[contentStart] == "\n" and self.__rawItem[contentStart + 1].startswith("\t"): @@ -915,6 +912,10 @@ def __userReplyParsing(self, replyContent: list, lineNumber: int) -> dict: if line == "\n": newLineCounter = newLineCounter + 1 + if newLineCounter == 2 and "datetime" not in replyFromInfo.keys(): + errorMessage = "Expected \"Date: [datetime]\" in the header info" + return self.__errorParsing(line, lineNumber + lineNum + 1, errorMessage) + elif line == "===============================================\n": endingDelimiterCount = endingDelimiterCount + 1 @@ -1190,7 +1191,19 @@ def __getUserAlias(self) -> str: Returns: str: User's Career Account alias if present or empty string """ - emailUser, emailDomain = self.userEmail.split("@") + + + try: + emailUser, emailDomain = self.userEmail.split("@") + + # Returns an error parse if the self.useremail doesn't contain exactally one "@" symbol + except ValueError: + # Parses through the self.headers list to find the "From" header and its line number + for lineNum, header in enumerate(self.headers): + if header["type"] == "From": + headerString = header["type"] + ": " + header["content"] + return self.__errorParsing(headerString, lineNum + 1, "Expected valid email Address") + return emailUser if emailDomain.endswith("purdue.edu") else "" def __getFormattedDate(self, date: str) -> str: @@ -1223,13 +1236,18 @@ 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: - """A collection of items. + """A collection of Items. Example: # Create a queue (ce) >>> queue = Queue("ce") + # Create a queue without parsing item contents (ce) + >>> queue = Queue("ce", headersOnly=False) + + Args: + queue (str): The name of the queue. + headersOnly (bool, optional): Whether or not to parse headers only. Defaults to True. Attributes: name: The name of the queue. @@ -1237,10 +1255,12 @@ class Queue: jsonData: A JSON serializable representation of the Queue. """ - def __init__(self, name: str) -> None: + def __init__(self, name: str, headersOnly: bool = True) -> None: self.name = name + self.headersOnly = headersOnly self.__directory = queueDirectory + "/" + self.name + "/" self.items = self.__getItems() + self._index = 0 self.jsonData = { "name": self.name, @@ -1261,7 +1281,7 @@ def __getItems(self) -> list: isFile = True if os.path.isfile(itemPath) else False if isFile and isValidItemName(item): - items.append(Item(self.name, item)) + items.append(Item(self.name, item, headersOnly=self.headersOnly)) return items @@ -1287,6 +1307,13 @@ def __len__(self) -> int: def __repr__(self) -> str: return f'{self.name}_queue' + # Implements the interable interface requirements by passing direct references + # to the item list's interable values. + def __iter__(self) -> list: + return iter(self.items) + def __next__(self) -> int: + return iter(self.items).__next__() + def getValidQueues() -> list: """Returns a list of queues on the filesystem excluding ignored queues. @@ -1329,20 +1356,37 @@ def getQueueCounts() -> list: queueInfo = [] for queue in getValidQueues(): possibleItems = os.listdir(queueDirectory + "/" + queue) - validItems = [isValidItemName for file in possibleItems] + validItems = [file for file in possibleItems if isValidItemName(file)] queueInfo.append( {"name": queue, "number_of_items": len(validItems)} ) - return queueInfo - + + # Sorts list of queue info alphabetically + sortedQueueInfo = sorted(queueInfo, key = lambda queueInfoList: queueInfoList['name']) -def loadQueues() -> list: + return sortedQueueInfo + +def loadAllQueues(headersOnly: bool = True) -> list: """Return a list of Queues for each queue. + Example: + # Load all Queues without parsing Item content + >>> loadAllQueues(); + Load all Queues and parsing Item content + >>> loadAllQueues(headersOnly=False) + + Args: + headersOnly (bool, optional): Whether or not to parse headers only. Defaults to True. + Returns: - list: list of Queues for each queue. + list: List of Queues for each queue. """ queues = [] for queue in getValidQueues(): - queues.append(Queue(queue)) + queues.append(Queue(queue, headersOnly=headersOnly)) return queues + +if __name__ == "__main__": + abe = Queue("abe") + abeCount = getQueueCounts() + print() \ No newline at end of file diff --git a/api/api.py b/api/api.py index 0cbd771..3a45319 100644 --- a/api/api.py +++ b/api/api.py @@ -5,8 +5,11 @@ 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 +from easyad import EasyAD +from ldap.filter import escape_filter_chars +# pylint says this is an error but it works so ¯\_(ツ)_/¯ +from ldap import INVALID_CREDENTIALS as LDAP_INVALID_CREDENTIALS import ECNQueue # Load envrionment variables for ./.env @@ -38,7 +41,7 @@ # 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' +app.config["JWT_REFRESH_COOKIE_PATH"] = '/api/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" @@ -47,6 +50,57 @@ +def user_is_valid(username: str, password: str) -> bool: + """Checks if user is valid and in webqueue2 login group. + + Args: + username (str): Career account username. + password (str): Career account passphrase. + + Returns: + bool: True if user is valid, otherwise False. + """ + + # Check for empty arguments + if (username == "" or password == ""): + return False + + # Initialize EasyAD + config = { + "AD_SERVER": "boilerad.purdue.edu", + "AD_DOMAIN": "boilerad.purdue.edu" + } + ad = EasyAD(config) + + # Prepare search critiera for Active Directory + credentials = { + "username": escape_filter_chars(username), + "password": password + } + attributes = [ 'cn', "memberOf" ] + filter_string = f'(&(objectClass=user)(|(sAMAccountName={username})))' + + # Do user search + try: + user = ad.search(credentials=credentials, attributes=attributes, filter_string=filter_string)[0] + except LDAP_INVALID_CREDENTIALS: + return False + + # Isolate group names + # Example: + # 'CN=00000227-ECNStuds,OU=BoilerADGroups,DC=BoilerAD,DC=Purdue,DC=edu' becomes + # `00000227-ECNStuds` + user_groups = [ group.split(',')[0].split('=')[1] for group in user["memberOf"] ] + + # Check group membership + webqueue_login_group = "00000227-ECN-webqueue" + if webqueue_login_group not in user_groups: + return False + + return True + + + class Login(Resource): def post(self) -> tuple: """Validates username/password and returns both access and refresh tokens. @@ -76,10 +130,8 @@ def post(self) -> tuple: 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) + if not user_is_valid(data["username"], data["password"]): + return ({ "message": "Username or password is invalid"}, 401) access_token = create_access_token(data["username"]) refresh_token = create_refresh_token(data["username"]) @@ -110,7 +162,6 @@ def get(self, queue: str, number: int) -> tuple: 200 (OK): On success. Example: - /api/ce/100 returns: { "lastUpdated": "07-23-20 10:11 PM", "headers": [...], @@ -135,13 +186,21 @@ def get(self, queue: str, number: int) -> tuple: Returns: tuple: Item as JSON and HTTP response code. """ - return (ECNQueue.Item(queue, number).toJson(), 200) + + headersOnly = True if request.args.get("headersOnly") == "True" else False + return ECNQueue.Item(queue, number, headersOnly=headersOnly).toJson() class Queue(Resource): @jwt_required def get(self, queues: str) -> tuple: """Returns the JSON representation of the queue requested. + Example: + { + "name": ce, + "items": [...] + } + Return Codes: 200 (OK): On success. @@ -151,12 +210,12 @@ def get(self, queues: str) -> tuple: Returns: tuple: Queues as JSON and HTTP response code. """ + headersOnly = False if request.args.get("headersOnly") == "False" else True queues_requested = queues.split("+") queue_list = [] for queue in queues_requested: - queue_list.append(ECNQueue.Queue(queue).toJson()) - + queue_list.append(ECNQueue.Queue(queue, headersOnly=headersOnly).toJson()) return (queue_list, 200) class QueueList(Resource): @@ -186,11 +245,11 @@ def get(self) -> tuple: -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") +api.add_resource(Login, "/api/login") +api.add_resource(RefreshAccessToken, "/api/tokens/refresh") +api.add_resource(Item, "/api/data//") +api.add_resource(Queue, "/api/data/") +api.add_resource(QueueList, "/api/data/get_queues") if __name__ == "__main__": app.run() \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt index 1aaa908..48ec2fb 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,22 +1,25 @@ -aniso8601==8.0.0 -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 -Jinja2==2.11.2 -lazy-object-proxy==1.4.3 -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 -typed-ast==1.4.1 -Werkzeug==1.0.1 -wrapt==1.12.1 +# The Python virtual environment should be managed via the venv-manager utility, not directly by pip. +# See: https://pip.pypa.io/en/stable/reference/pip_install/#example-requirements-file + +# General Utilities +gunicorn +pipdeptree +pylint + +# API +python-dotenv +Flask-RESTful +python-dateutil +Flask-JWT-Extended +# Flask-JWT-Extended doesn't support PyJWT 2.x as of 3.25.0 +# Prevent upgrade to PyJWT 2.x until Flask-JWT-Extended is updated to support it. +# Check: https://github.com/vimalloc/flask-jwt-extended/tags +PyJWT == 1.* +# Custom python-ldap without SASL +git+https://github.itap.purdue.edu/ECN/python-ldap.git@python-ldap-3.3.1#egg=python-ldap==3.3.1 +easyad + +# API Documentation +mkdocs +mkdocs-material +mkautodoc \ No newline at end of file diff --git a/package.json b/package.json index 8b496df..2a7a20a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "webqueue2", - "homepage": "/qwebtest/", + "homepage": "", "proxy": "http://localhost:5000/", - "version": "0.1.0", + "version": "0.9.1", "private": true, "dependencies": { "@material-ui/core": "^4.10.2", @@ -14,7 +14,6 @@ "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", diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..43e0c37 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,17 @@ +# Enable the rewrite module +RewriteEngine On + +# Reverse proxy all requests to the API to the API CGI script +# See mod_rewrite docs: https://httpd.apache.org/docs/current/mod/mod_rewrite.html +RewriteRule ^api/(.*)$ http://localhost:5000/api/$1 [P] + +# Defer requests that are not files to client side routing +# See: https://create-react-app.dev/docs/deployment/#serving-apps-with-client-side-routing +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^ index.html [QSA,L] + +# Uncomment the following line to serve a maintenance message. +#DirectoryIndex index-maintenance.html + +# Diable Auth +Satisfy any \ No newline at end of file diff --git a/public/index-maintenance.html b/public/index-maintenance.html new file mode 100644 index 0000000..9f35883 --- /dev/null +++ b/public/index-maintenance.html @@ -0,0 +1,67 @@ + + + + + + + + webqueue2 + + + + +
+
+

Under Maintenance

+
+

webqueue2 is having some work done. Please check back later.

+

Mar 14 2021 @ 11:30 PM EDT

+
+
+ + + \ No newline at end of file diff --git a/src/auth/utilities.js b/src/auth/utilities.js index 8800954..2a318e8 100644 --- a/src/auth/utilities.js +++ b/src/auth/utilities.js @@ -17,7 +17,7 @@ export async function login(username, password){ body: JSON.stringify({ "username": username, "password": password}) }; - let loginResponse = await fetch("/login", loginInit); + let loginResponse = await fetch(`${process.env.PUBLIC_URL}/api/login`, loginInit); let data = await loginResponse.json(); if (data === null){ @@ -44,7 +44,7 @@ export async function refresh(csrf_refresh_token){ headers: {'X-CSRF-TOKEN': csrf_refresh_token}, }; - let refreshResponse = await fetch("/tokens/refresh", refreshInit); + let refreshResponse = await fetch(`${process.env.PUBLIC_URL}/api/tokens/refresh`, refreshInit); let data = await refreshResponse.json(); if (data === null){ @@ -56,4 +56,4 @@ export async function refresh(csrf_refresh_token){ } 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 index 75c7c60..bbd8cd2 100644 --- a/src/components/AppView/AppView.js +++ b/src/components/AppView/AppView.js @@ -11,46 +11,59 @@ import QueueSelector from "../QueueSelector/"; import { useToken } from "../AuthProvider/"; export default function AppView({ setDarkMode }){ - const [activeItem, setActiveItem] = useState({}); + // Create stateful variables. const [sidebarOpen, setSidebarOpen] = useState(false); const [queues, setQueues] = useState([]); const [items, setItems] = useState([]); const [selectedQueues, setSelectedQueues] = useState([]); const [queueSelectorOpen, setQueueSelectorOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); const access_token = useToken(); + // Get Queues from API. useEffect( _ => { - async function getQueues(){ + (async function getQueues(){ if (access_token === null){ - return undefined + return undefined; } if (queueSelectorOpen){ - return undefined + return undefined; } - if (selectedQueues.length > 0){ - let queuesToLoad = ""; - - for (let selectedQueue of selectedQueues){ - queuesToLoad += `+${selectedQueue.name}`; - } + if (selectedQueues.length === 0){ + setQueues([]) + return undefined; + } - let myHeaders = new Headers(); - myHeaders.append("Authorization", `Bearer ${access_token}`); - let requestOptions = { headers: myHeaders }; + setIsLoading(true); + let queuesToLoad = ""; - const apiResponse = await fetch(`/api/${queuesToLoad}`, requestOptions); - const queueJson = await apiResponse.json(); - setQueues(queueJson); - } else { - setQueues([]) + if (selectedQueues.length === 1){ + queuesToLoad = selectedQueues[0].name; } - } - getQueues(); + else { + selectedQueues.forEach( (queue, index) => ( + index === 0 + ? queuesToLoad += queue.name + : queuesToLoad += `+${queue.name}` + )); + } + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`${process.env.PUBLIC_URL}/api/data/${queuesToLoad}`, requestOptions); + const queueJson = await apiResponse.json(); + + setQueues(queueJson); + setIsLoading(false) + })(); }, [selectedQueues, access_token, queueSelectorOpen]); + // Populate items array. useEffect( _ => { let tempItems = []; for (let queue of queues){ @@ -94,7 +107,6 @@ export default function AppView({ setDarkMode }){ 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 ( - <> - - - - ); - } - } + render={({ match }) => ( + <> + + + + )} /> } @@ -140,4 +142,4 @@ export default function AppView({ setDarkMode }){ AppView.propTypes = {}; -AppView.defaultProps = {}; \ No newline at end of file +AppView.defaultProps = {}; diff --git a/src/components/ItemBodyView/ItemBodyView.js b/src/components/ItemBodyView/ItemBodyView.js index 8811fdd..63ef7c5 100644 --- a/src/components/ItemBodyView/ItemBodyView.js +++ b/src/components/ItemBodyView/ItemBodyView.js @@ -6,9 +6,10 @@ import DirectoryInformation from "../DirectoryInformation/"; import Assignment from "../Assignment/"; import TimelineActionCard from "../TimelineActionCard/"; import MessageView from "../MessageView/"; -import { objectIsEmpty } from "../../utilities"; +import ParseError from "../ParseError/"; +import TimelineSkeleton from "../TimelineSkeleton/"; -export default function ItemBodyView({ item }) { +export default function ItemBodyView({ sections, loading }) { const useStyles = makeStyles(() => ({ "Timeline-root": { @@ -29,6 +30,11 @@ export default function ItemBodyView({ item }) { })); const classes = useStyles(); + /** + * Returns a section type specific timeline item. + * @param {Object} section The + * @returns {Node} A section type specific timeline item. + */ const generateTimelineItem = (section) => { switch (section.type) { case "directory_information": @@ -71,30 +77,48 @@ export default function ItemBodyView({ item }) { }; }; + const TimelineItemTemplate = ({ children }) => ( + + + + + + + {children} + + + ); + return ( - {objectIsEmpty(item) ? "" : item.content.map((section) => ( - - - - - - - {generateTimelineItem(section)} - - - ))} + { loading + ? ( // Generate 3 placeholders. + [...Array(3).keys()].map( (_, index) => ( + + + + )) + ) + : ( // Generate timeline. + sections.map((section) => ( + + {generateTimelineItem(section)} + + )) + ) + } ); }; ItemBodyView.propTypes = { - /** The item to diplay. */ - "item": PropTypes.object.isRequired -}; \ No newline at end of file + /** Section of an item to display. */ + "sections": PropTypes.array, + /** If true, shows loading placeholder. */ + "loading": PropTypes.bool +}; + +ItemBodyView.defaultProps = { + "sections": [], + "loading": false +} \ No newline at end of file diff --git a/src/components/ItemBodyView/ItemBodyView.md b/src/components/ItemBodyView/ItemBodyView.md index 6a741ea..f92f979 100644 --- a/src/components/ItemBodyView/ItemBodyView.md +++ b/src/components/ItemBodyView/ItemBodyView.md @@ -8,15 +8,16 @@ The ItemBodyView displays the seven actions possible in an item: - **Reply To User:** a message sent from an ECN employee to a user. - **Reply To ECN:** a message sent from the user to ECN that has been merged into an existing item. -```jsx -import ItemBodyView from "./ItemBodyView"; +## Default View +![ItemBodyView_Loaded](/ItemBodyView_Loaded.png) -const demoItem = {"queue": "ce", "number": 100, "lastUpdated": "09-28-20 01:26 PM", "headers": [{"type": "Merged-Time", "content": "Tue, 23 Jun 2020 13:31:53 -0400"}, {"type": "Merged-By", "content": "campb303"}, {"type": "QTime", "content": "1"}, {"type": "QTime-Updated-Time", "content": "Tue, 23 Jun 2020 13:28:50 EDT"}, {"type": "QTime-Updated-By", "content": "campb303"}, {"type": "Time", "content": "1"}, {"type": "Time-Updated-Time", "content": "Tue, 23 Jun 2020 13:28:50 EDT"}, {"type": "Time-Updated-By", "content": "campb303"}, {"type": "Replied-Time", "content": "Tue, 23 Jun 2020 13:28:48 -0400"}, {"type": "Replied-By", "content": "campb303"}, {"type": "Edited-Time", "content": "Tue, 23 Jun 2020 13:27:56 -0400"}, {"type": "Edited-By", "content": "campb303"}, {"type": "QAssigned-To", "content": "campb303"}, {"type": "QAssigned-To-Updated-Time", "content": "Tue, 23 Jun 2020 13:27:00 EDT"}, {"type": "QAssigned-To-Updated-By", "content": "campb303"}, {"type": "Assigned-To", "content": "campb303"}, {"type": "Assigned-To-Updated-Time", "content": "Tue, 23 Jun 2020 13:27:00 EDT"}, {"type": "Assigned-To-Updated-By", "content": "campb303"}, {"type": "QStatus", "content": "Dont Delete"}, {"type": "QStatus-Updated-Time", "content": "Tue, 23 Jun 2020 13:26:55 EDT"}, {"type": "QStatus-Updated-By", "content": "campb303"}, {"type": "Status", "content": "Dont Delete"}, {"type": "Status-Updated-Time", "content": "Tue, 23 Jun 2020 13:26:55 EDT"}, {"type": "Status-Updated-By", "content": "campb303"}, {"type": "Date", "content": "Tue, 23 Jun 2020 13:25:51 -0400"}, {"type": "From", "content": "Justin Campbell "}, {"type": "Message-ID", "content": "<911CE050-3240-4980-91DD-9C3EBB8DBCF8@purdue.edu>"}, {"type": "Subject", "content": "Beepboop"}, {"type": "To", "content": "cesite@ecn.purdue.edu"}, {"type": "Content-Type", "content": "text/plain; charset=\"utf-8\""}, {"type": "X-ECN-Queue-Original-Path", "content": "/home/pier/e/queue/Attachments/inbox/2020-06-23/208-original.txt"}, {"type": "X-ECN-Queue-Original-URL", "content": "https://engineering.purdue.edu/webqueue/Attachments/inbox/2020-06-23/208-original.txt"}], "content": [{"type": "directory_information", "Name": "Heyi Feng", "Login": "feng293", "Computer": "civil4147pc2.ecn", "Location": "HAMP 4147", "Email": "feng293@purdue.edu", "Phone": "5039154835", "Office": "", "UNIX Dir": "None", "Zero Dir": "U=\\\\myhome.itap.purdue.edu\\myhome\\%username%", "User ECNDB": "http://eng.purdue.edu/jump/2e29495", "Host ECNDB": "http://eng.purdue.edu/jump/2eccc3f", "Subject": "Upgrade system and Abaqus"}, {"type": "assignment", "datetime": "2020-06-23T13:27:00-0400", "by": "campb303", "to": "campb303"}, {"type": "initial_message", "datetime": "2020-06-23T13:25:51-0400", "from_name": "Justin Campbell", "user_email": "campb303@purdue.edu", "to": [{"name": "", "email": "cesite@ecn.purdue.edu"}], "cc": [], "content": ["Testtest\n"]}, {"type": "status", "datetime": "2020-06-23T13:26:55", "by": "campb303", "content": ["Dont Delete\n"]}, {"type": "edit", "datetime": "2020-06-23T13:27:56", "by": "campb303", "content": ["This be an edit my boy\n"]}, {"type": "reply_to_user", "datetime": "2020-06-23T13:28:18", "by": "campb303", "content": ["This be a reply my son\n", "\n", "Justin\n", "ECN\n"]}, {"type": "reply_from_user", "datetime": "2020-06-23T13:30:45-0400", "from_name": "Justin Campbell", "from_email": "campb303@purdue.edu", "cc": [], "content": ["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"]}], "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": "", "department": "", "building": "", "dateReceived": "2020-06-23T13:25:51-0400"}; -
- -
+```jsx static + ``` +## Loading View +![ItemBodyView_Loaded](/ItemBodyView_Loading.gif) + ```jsx static - + ``` \ No newline at end of file diff --git a/src/components/ItemMetadataView/ItemMetadataView.js b/src/components/ItemMetadataView/ItemMetadataView.js index 05f6357..6c4e4aa 100644 --- a/src/components/ItemMetadataView/ItemMetadataView.js +++ b/src/components/ItemMetadataView/ItemMetadataView.js @@ -33,11 +33,6 @@ export default function ItemMetadataView({item}){ {item.subject} - - - Status, assignments, priority, refile, archive and delete controls coming soon to a theater near you. - - ); } diff --git a/src/components/ItemProvider/ItemProvider.js b/src/components/ItemProvider/ItemProvider.js new file mode 100644 index 0000000..b3d15e3 --- /dev/null +++ b/src/components/ItemProvider/ItemProvider.js @@ -0,0 +1,21 @@ +import React, { useState, createContext, useContext, useEffect } from "react"; + +const ItemContext = createContext(); +const ItemSetterContext = createContext(); + +export const useItem = () => useContext(ItemContext); +export const useItemSetter = () => useContext(ItemSetterContext); + + + +export default function ItemProvider({ children }) { + const [item, setItem] = useState( {} ); + + return ( + + + {children} + + + ); +}; \ No newline at end of file diff --git a/src/components/ItemProvider/ItemProvider.md b/src/components/ItemProvider/ItemProvider.md new file mode 100644 index 0000000..4653d82 --- /dev/null +++ b/src/components/ItemProvider/ItemProvider.md @@ -0,0 +1,41 @@ +Utility component that uses [React Contexts](https://reactjs.org/docs/context.html) [React Stateful Variables](https://reactjs.org/docs/hooks-state.html) to provide global access to the active item object. + +Two functions are exported: + +Function | Descrioption +- | - +`useItem` | Returns a reference to the state variable holding the current Item. Defaults to `false`. +`useItemSetter` | Returns a reference to the state variable update function. + +For an in depth explanation of this pattern, see [this GitHub comment](https://github.itap.purdue.edu/ECN/webqueue2/issues/15#issuecomment-341). + + +## Usage +```jsx static +// App + + + +``` + +```jsx static +// SomeComponent +import { useEffect } from "react"; +import { useItem, useItemSetter } from "ItemProvider"; + +const activeItem = useItem(); +const setActiveItem = useItemSetter(); + +useEffect( + let item = someFuncToGetItem(); + setActiveItem(item); +); + +return( + { + item + ?

{`${activeItem.queue} ${activeItem.number} was last updated ${activeItem.lastUpdated}.`}

+ :

No item is currently loaded.

+ } +); +``` \ No newline at end of file diff --git a/src/components/ItemProvider/index.js b/src/components/ItemProvider/index.js new file mode 100644 index 0000000..3186bd3 --- /dev/null +++ b/src/components/ItemProvider/index.js @@ -0,0 +1 @@ +export {default, useItem, useItemSetter } from "./ItemProvider"; \ No newline at end of file diff --git a/src/components/ItemTable/ItemTable.js b/src/components/ItemTable/ItemTable.js index e45dc63..3063df0 100644 --- a/src/components/ItemTable/ItemTable.js +++ b/src/components/ItemTable/ItemTable.js @@ -1,25 +1,31 @@ import React, { useState } from "react"; import PropTypes from "prop-types"; import { useTable, useFilters, useFlexLayout, useSortBy } from "react-table"; -import { Table, TableBody, TableCell, TableHead, TableRow, TableContainer, Paper, Grid, ButtonGroup, IconButton, makeStyles, useTheme } from "@material-ui/core"; +import { Table, TableBody, TableCell, TableHead, TableRow, TableContainer, Box, Grid, 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"; +import ItemTableFilter from "../ItemTableFilter/"; +import ItemtTableSortButtons from "../ItemTableSortButtons/"; +import ItemTableCell from "../ItemTableCell"; +import LastUpdatedCell from "../LastUpdatedCell/"; +import jester from "./loading-annimation.gif"; +export default function ItemTable({ data, rowCanBeSelected, loading }) { + const [selectedRow, setSelectedRow] = useState({ queue: null, number: null }); -export default function ItemTable({ data, rowCanBeSelected }) { - const [selectedRow, setSelectedRow] = useState({ queue: null, number: null}); - - const theme = useTheme(); + const theme = useTheme(); const useStyles = makeStyles({ - // Fully visible for active icons - activeSortIcon: { - opacity: 1, + loadingAnnimation: { + display: "flex", + justifyContent: "center", + width: "100%" }, - // Half visible for inactive icons - inactiveSortIcon: { - opacity: 0.2, + hoverBackgroundColor: { + "&:hover": { + // The !important is placed here to enforce CSS specificity. + // See: https://material-ui.com/styles/advanced/#css-injection-order + backgroundColor: `${theme.palette.primary[200]} !important`, + }, }, rowSelected: { backgroundColor: theme.palette.type === 'light' ? theme.palette.primary[100] : theme.palette.primary[600], @@ -34,29 +40,38 @@ export default function ItemTable({ data, rowCanBeSelected }) { borderLeftStyle: "solid", borderColor: theme.palette.type === "light" ? theme.palette.grey[300] : theme.palette.grey[500] }, + tableMargin: { + marginTop: theme.spacing(2) + }, + tableHeaderPadding: { + paddingBottom: theme.spacing(2) + }, }); const classes = useStyles(); const history = useHistory(); + // See React Table Column Settings: https://react-table.tanstack.com/docs/api/useTable#column-properties const columns = React.useMemo( () => [ { Header: 'Queue', accessor: 'queue', }, { Header: 'Item #', accessor: 'number' }, + { Header: 'From', accessor: 'userAlias' }, { Header: 'Assigned To', accessor: 'assignedTo' }, { Header: 'Subject', accessor: 'subject' }, { Header: 'Status', accessor: 'status', }, { Header: 'Priority', accessor: 'priority' }, - { Header: 'Last Updated', accessor: 'lastUpdated', Cell: ({ value }) => }, + { Header: 'Last Updated', accessor: 'lastUpdated', sortInverted: true, Cell: ({ value }) => }, { Header: 'Department', accessor: 'department' }, { Header: 'Building', accessor: 'building' }, - { Header: 'Date Received', accessor: 'dateReceived', Cell: ({ value }) => }, + { Header: 'Date Received', accessor: 'dateReceived', sortInverted: true, Cell: ({ value }) => }, ], []); - const tableInstance = useTable( { columns, data, + autoResetSortBy: false, + autoResetFilters: false, defaultColumn: { Filter: ({ column: { Header, setFilter } }) => { return ( @@ -65,103 +80,149 @@ export default function ItemTable({ data, rowCanBeSelected }) { onChange={(event) => setFilter(event.target.value)} /> ); - } + }, }, + initialState: { + sortBy: [ + { id: "queue" }, + { id: 'number' }, + { id: 'lastUpdated' }, + ], + } }, useFilters, useFlexLayout, useSortBy, ); const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, } = tableInstance; return ( - - - - {headerGroups.map(headerGroup => ( - - {headerGroup.headers.map(column => ( - - + loading ? ( + + Items are loading. + + ) : ( + +
+ + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => { + // Determine sort directions + const isSortedAsc = column.isSorted && !column.isSortedDesc; + const isSortedDesc = column.isSorted && column.isSortedDesc; + + return ( - - {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) - })} - > - + + + {column.render("Filter")} + + + (isSortedAsc ? column.clearSortBy() : column.toggleSortBy(false)) + }} + sortDescArrowProps={{ + ...column.getSortByToggleProps(), + onClick: _ => (isSortedDesc ? column.clearSortBy() : column.toggleSortBy(true)) + }} + sortDirection={(_ => { + if (isSortedAsc) { + return 'asc'; + } + else if (isSortedDesc) { + return 'desc'; + } + else { + return undefined; + } + })()} /> - - - + + + - - - ))} - - ))} - - - {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}`); - 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")} - - ))} + ); + })} - ); - })} - -
-
+ ))} + + + + {rows.map((row) => { + prepareRow(row); + let isSelected = selectedRow.queue === row.original.queue && selectedRow.number === row.original.number + return ( + { + 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, + hover: classes.hoverBackgroundColor + }} + {...row.getRowProps()} + > + {row.cells.map(cell => ( + cell.render(_ => { + switch (cell.column.id) { + case "dateReceived": + return ( + + + + ); + case "lastUpdated": + return ( + + ); + default: + return ( + + {cell.value} + + ); + } + }) + ))} + + ); + })} + + + + ) ); -}; +} ItemTable.propTypes = { /** Array of items from all active queues to display in table. */ "items": PropTypes.array, - /** State variable indicating if rows can be selected. When false, all rows are deselected. */ - "rowCanBeSelected": PropTypes.bool + /** If true, rows can be selected. */ + "rowCanBeSelected": PropTypes.bool, + /** If true, ItemTable displays loading screen. */ + "loading": PropTypes.bool }; ItemTable.defaultProps = { - /** The items to display in the table. */ "items": [], - /** A state variable determining whether a row can be selected or not. */ - "rowCanBeSelected": true + "rowCanBeSelected": true, + "loading": false }; diff --git a/src/components/ItemTable/loading-annimation.gif b/src/components/ItemTable/loading-annimation.gif new file mode 100644 index 0000000..c657308 Binary files /dev/null and b/src/components/ItemTable/loading-annimation.gif differ diff --git a/src/components/ItemTableCell/ItemTableCell.js b/src/components/ItemTableCell/ItemTableCell.js new file mode 100644 index 0000000..38bacee --- /dev/null +++ b/src/components/ItemTableCell/ItemTableCell.js @@ -0,0 +1,41 @@ +import React from 'react' +import PropTypes from "prop-types"; +import { makeStyles, TableCell, useTheme } from '@material-ui/core' + +export default function ItemTableCell({ children, TableCellProps }) { + const theme = useTheme(); + const useStyles = makeStyles({ + columnBorders: { + // Add column borders + borderLeftWidth: "1px", + borderLeftStyle: "solid", + borderColor: theme.palette.type === "light" ? theme.palette.grey[300] : theme.palette.grey[500] + }, + }) + + const classes = useStyles(); + return ( + + {children} + + ); +} + +ItemTableCell.propTypes = { + /** Child object passed to display cell data. */ + "children": PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.element + ]), + /** Props applied to the TableCell component. */ + "TableCellProps": PropTypes.object +}; + +ItemTableCell.defaultProps = { + "children": {}, + "TableCellProps": {}, +}; \ No newline at end of file diff --git a/src/components/ItemTableCell/ItemTableCell.md b/src/components/ItemTableCell/ItemTableCell.md new file mode 100644 index 0000000..5a3bcea --- /dev/null +++ b/src/components/ItemTableCell/ItemTableCell.md @@ -0,0 +1,38 @@ +The ItemTableCell wraps an [MUI TableCell](https://material-ui.com/api/table-cell/) and adds styling. + +## Default Usage +```jsx +import { Paper } from '@material-ui/core'; +import ItemTableCell from "./ItemTableCell"; + + + + Hello, moto! + + +``` + +```jsx static + + + Hello, moto! + + +``` + +## Forwarded TableCell Props +Props can be passed to the TableCell component using the TableCellProps prop. +```jsx +import { Paper } from '@material-ui/core'; +import ItemTableCell from "./ItemTableCell"; + + + Hello, moto! + +``` + +```jsx static + + Hello, moto! + +``` \ No newline at end of file diff --git a/src/components/ItemTableCell/index.js b/src/components/ItemTableCell/index.js new file mode 100644 index 0000000..a358a7b --- /dev/null +++ b/src/components/ItemTableCell/index.js @@ -0,0 +1,3 @@ +import ItemTableCell from "./ItemTableCell"; + +export default ItemTableCell; \ No newline at end of file diff --git a/src/components/ItemTableFilter/ItemTableFilter.js b/src/components/ItemTableFilter/ItemTableFilter.js index b5a2fae..2c61967 100644 --- a/src/components/ItemTableFilter/ItemTableFilter.js +++ b/src/components/ItemTableFilter/ItemTableFilter.js @@ -1,35 +1,35 @@ -import React, { useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles, TextField } from "@material-ui/core"; +import { Box, FormControl, InputLabel, makeStyles, OutlinedInput,} from "@material-ui/core"; export default function ItemTableFilter({ label, onChange }) { - const useStyles = makeStyles({ - labelRoot: { - overflow: "hidden" - }, - labelFocused: { - overflow: "visible" + filterContainer: { + overflowX: "hidden" }, }); const classes = useStyles(); - const [isFocused, setIsFocused] = useState(false); - + // The FormControl is wrapped in a box with overflowX=hidden to prevent the + // InputLabel text from going outside its textfield. + // See: https://github.itap.purdue.edu/ECN/webqueue2/issues/156 return ( - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - className={ isFocused ? classes.labelFocused : classes.labelRoot } + + + > + {label} + + + ); - }; ItemTableFilter.propTypes = { @@ -41,4 +41,4 @@ ItemTableFilter.propTypes = { ItemTableFilter.defaultProps = { "label": "" -} \ No newline at end of file +} diff --git a/src/components/ItemTableSortButtons/ItemTableSortButtons.js b/src/components/ItemTableSortButtons/ItemTableSortButtons.js new file mode 100644 index 0000000..564c1fb --- /dev/null +++ b/src/components/ItemTableSortButtons/ItemTableSortButtons.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from "prop-types"; +import { ButtonGroup, IconButton } from "@material-ui/core"; +import { ArrowDownward, ArrowUpward } from "@material-ui/icons"; + +export default function ItemTableSortButtons({ sortDirection, sortAscArrowProps, sortDescArrowProps }) { + return ( + + + + + + + + + ) +} + +ItemTableSortButtons.propTypes = { + /** String representing sort direction. */ + "sortDirection": PropTypes.oneOf(['asc', 'desc', undefined ]), + /** Props passed to ArrowUpward component. */ + "sortAscArrowProps": PropTypes.object, + /** Props passed to ArrowDownward component. */ + "sortDescArrowProps": PropTypes.object +}; + +ItemTableSortButtons.defaultProps = { + "sortDirection": undefined, + "sortAscArrowProps": { onClick: _ => alert("No onClick function set. This does nothing.") }, + "sortDescArrowProps": { onClick: _ => alert("No onClick function set. This does nothing.") } +}; + diff --git a/src/components/ItemTableSortButtons/ItemTableSortButtons.md b/src/components/ItemTableSortButtons/ItemTableSortButtons.md new file mode 100644 index 0000000..33037b2 --- /dev/null +++ b/src/components/ItemTableSortButtons/ItemTableSortButtons.md @@ -0,0 +1,68 @@ +The ItemTableSortButtons are used to sort in ascending or descending order based on which button is selected. It is to be used with the [ItemTable](/#/Components/ItemTable). + +## Default Usage +```jsx +import React, { useState, useEffect } from "react"; +import { Paper, TableCell, } from "@material-ui/core"; +import ItemTableFilter from "../ItemTableFilter/"; + + + + + + + + + + +``` +```jsx static + + +``` +Used without any props, the ItemTableSort will display arrows with default styling. + + +## Sorting by Ascending +If the `sortDirection` prop is passed `asc`, the ItemTableSortButtons will display the active styling for the ascending arrow. If a onClick function is present the table will run that function on button click +```jsx +import React, { useState, useEffect } from "react"; +import { Paper, TableCell, } from "@material-ui/core"; +import ItemTableFilter from "../ItemTableFilter/"; + + + + + + + + + + +``` +```jsx static + +``` +## Sorting by Descending +If the `sortDirection` prop is passed `desc`, the ItemTableSortButtons will display the active styling for the descending arrow. If a onClick function is present the table will run that function on button click +```jsx +import React, { useState, useEffect } from "react"; +import { Paper, TableCell, } from "@material-ui/core"; +import ItemTableFilter from "../ItemTableFilter/"; + + + + + + + + + + +``` +```jsx static + +``` + + + diff --git a/src/components/ItemTableSortButtons/index.js b/src/components/ItemTableSortButtons/index.js new file mode 100644 index 0000000..390adb0 --- /dev/null +++ b/src/components/ItemTableSortButtons/index.js @@ -0,0 +1,3 @@ +import ItemtTableSortButtons from './ItemTableSortButtons' + +export default ItemtTableSortButtons; \ No newline at end of file diff --git a/src/components/ItemView/ItemView.js b/src/components/ItemView/ItemView.js index 8bd7eda..1b774ac 100644 --- a/src/components/ItemView/ItemView.js +++ b/src/components/ItemView/ItemView.js @@ -1,15 +1,57 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from "prop-types"; -import { Paper, AppBar, Tab, makeStyles, useTheme } from '@material-ui/core'; +import { Paper, AppBar, Tab, makeStyles, useTheme, Box, Typography } 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 ItemMetadataView from "../ItemMetadataView" import ItemBodyView from "../ItemBodyView"; import ItemHeaderView from "../ItemHeaderView"; +import { useItem, useItemSetter } from "../ItemProvider"; +import { useToken } from "../AuthProvider/"; -export default function ItemView({ activeItem }){ +export default function ItemView({ queue, number }) { + // Set stateful variables const [activeTab, setActiveTab] = useState('Conversation'); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(false); + + // Set contextual variables + const activeItem = useItem(); + const setActiveItem = useItemSetter(); + const access_token = useToken(); + + // Get full Item from API + useEffect(_ => { + (async _ => { + if (access_token === null) { + return undefined; + } + + if (activeItem.queue === queue && activeItem.number == number) { + return undefined; + } + + setIsLoading(true); + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`${process.env.PUBLIC_URL}/api/data/${queue}/${number}`, requestOptions); + + if (!apiResponse.ok) { + console.error(`Fetching item ${queue}${number}. Got code ${apiResponse.status} (${apiResponse.statusText})`); + setError(true); + return undefined; + } + + const itemJson = await apiResponse.json(); + + setActiveItem(itemJson); + setIsLoading(false) + })(); + }, [access_token, activeItem, setActiveItem, queue, number]); const theme = useTheme(); const useStyles = makeStyles({ @@ -21,6 +63,13 @@ export default function ItemView({ activeItem }){ }, "tabPanelPadding": { padding: `${theme.spacing(2)}px ${theme.spacing(2)}px` + }, + errorContainer: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + marginTop: theme.spacing(6) } }); const classes = useStyles(); @@ -29,28 +78,48 @@ export default function ItemView({ activeItem }){ setActiveTab(newValue); }; - return( - - - - - - - - - - - - - - - - + return ( + + {error ? ( + + + 4☹4 + + + Something went wrong. + + + {`Item ${queue}${number} could not be found.`} + + + ) : ( + <> + + + + + + + + + + + + + + + + + + + )} ); }; ItemView.propTypes = { - /** The item to be viewed. */ - "activeItem": PropTypes.object.isRequired + /** The queue of the item to load. */ + "queue": PropTypes.string.isRequired, + /** The number of the item to load. */ + "number": PropTypes.number.isRequired }; \ No newline at end of file diff --git a/src/components/ItemView/ItemView.md b/src/components/ItemView/ItemView.md index a9e1ef0..61eee38 100644 --- a/src/components/ItemView/ItemView.md +++ b/src/components/ItemView/ItemView.md @@ -1,13 +1,5 @@ The ItemView is the primary view for an item. It displays the messages and actions in a timeline view. -```jsx -import React, { useState } from "react"; - -const demoItem = {"queue": "ce", "number": 100, "lastUpdated": "07-23-20 10:11 PM", "headers": [{"type": "Merged-Time", "content": "Tue, 23 Jun 2020 13:31:53 -0400"}, {"type": "Merged-By", "content": "campb303"}, {"type": "QTime", "content": "1"}, {"type": "QTime-Updated-Time", "content": "Tue, 23 Jun 2020 13:28:50 EDT"}, {"type": "QTime-Updated-By", "content": "campb303"}, {"type": "Time", "content": "1"}, {"type": "Time-Updated-Time", "content": "Tue, 23 Jun 2020 13:28:50 EDT"}, {"type": "Time-Updated-By", "content": "campb303"}, {"type": "Replied-Time", "content": "Tue, 23 Jun 2020 13:28:48 -0400"}, {"type": "Replied-By", "content": "campb303"}, {"type": "Edited-Time", "content": "Tue, 23 Jun 2020 13:27:56 -0400"}, {"type": "Edited-By", "content": "campb303"}, {"type": "QAssigned-To", "content": "campb303"}, {"type": "QAssigned-To-Updated-Time", "content": "Tue, 23 Jun 2020 13:27:00 EDT"}, {"type": "QAssigned-To-Updated-By", "content": "campb303"}, {"type": "Assigned-To", "content": "campb303"}, {"type": "Assigned-To-Updated-Time", "content": "Tue, 23 Jun 2020 13:27:00 EDT"}, {"type": "Assigned-To-Updated-By", "content": "campb303"}, {"type": "QStatus", "content": "Dont Delete"}, {"type": "QStatus-Updated-Time", "content": "Tue, 23 Jun 2020 13:26:55 EDT"}, {"type": "QStatus-Updated-By", "content": "campb303"}, {"type": "Status", "content": "Dont Delete"}, {"type": "Status-Updated-Time", "content": "Tue, 23 Jun 2020 13:26:55 EDT"}, {"type": "Status-Updated-By", "content": "campb303"}, {"type": "Date", "content": "Tue, 23 Jun 2020 13:25:51 -0400"}, {"type": "From", "content": "Justin Campbell "}, {"type": "Message-ID", "content": "<911CE050-3240-4980-91DD-9C3EBB8DBCF8@purdue.edu>"}, {"type": "Subject", "content": "Beepboop"}, {"type": "To", "content": "cesite@ecn.purdue.edu"}, {"type": "Content-Type", "content": "text/plain; charset=\"utf-8\""}, {"type": "X-ECN-Queue-Original-Path", "content": "/home/pier/e/queue/Attachments/inbox/2020-06-23/208-original.txt"}], "content": ["Testtest\n", "\n", "*** Status updated by: campb303 at: 6/23/2020 13:26:55 ***\n", "Dont Delete\n", "*** Edited by: campb303 at: 06/23/20 13:27:56 ***\n", "\n", "This be an edit my boy\n", "\n", "\n", "\n", "*** 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", "=== 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"], "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": "", "department": "", "building": "", "dateReceived": "Tue, 23 Jun 2020 13:25:51 -0400"}; - - -``` - ```jsx static - + ``` \ No newline at end of file diff --git a/src/components/LastUpdatedCell/LastUpdatedCell.js b/src/components/LastUpdatedCell/LastUpdatedCell.js new file mode 100644 index 0000000..82221fc --- /dev/null +++ b/src/components/LastUpdatedCell/LastUpdatedCell.js @@ -0,0 +1,73 @@ +import React from 'react' +import PropTypes from "prop-types"; +import { useTheme } from '@material-ui/core'; +import { red } from '@material-ui/core/colors'; +import RelativeTime from 'react-relative-time'; +import ItemTableCell from '../ItemTableCell'; + +export default function LastUpdatedCell({ time, ItemTableCellProps }) { + + const theme = useTheme(); + + /** + * Returns a color showing how old an item is. + * @param {string} time ISO 8601 formatted time string. + * @example + * // Returns "#e57373" + * timeToBackgroundColor("2021-01-04T11:47:00-0500") + * @returns {string} Hex color code. + */ + const timeToBackgroundColor = (time) => { + const lastUpdated = new Date(time).getTime(); + const now = new Date().getTime(); + const timeDelta = now - lastUpdated; + + const day = 24 * 60 * 60 * 1000; + const week = day * 7; + const month = week * 4; + + let backgroundColor = theme.palette.background.paper; + + // 1-6 days old + if (timeDelta > day && timeDelta <= week) { + backgroundColor = red[100]; + } + // 7-28 days old + else if (timeDelta > week && timeDelta <= month) { + backgroundColor = red[300]; + } + // 29+ days old + else if (timeDelta > month) { + backgroundColor = red[500]; + } + + return backgroundColor; + } + + // Insert the calculated background color into props so it isn't overriden. + // Inspired by: https://github.com/mui-org/material-ui/issues/19479 + ItemTableCellProps = { + ...ItemTableCellProps, + style: { + ...ItemTableCellProps.style, + backgroundColor: timeToBackgroundColor(time) + } + }; + + return ( + + + + ); +}; + +LastUpdatedCell.propTypes = { + /** ISO 8601 formatted time string, Date object or UNIX time. See: https://www.npmjs.com/package/react-relative-time */ + "time": PropTypes.string.isRequired, + /** Props to be applied to the ItemTableCell. */ + "ItemTableCellProps": PropTypes.object, +}; + +LastUpdatedCell.defaultProps = { + "ItemTableCellProps": {}, +}; \ No newline at end of file diff --git a/src/components/LastUpdatedCell/LastUpdatedCell.md b/src/components/LastUpdatedCell/LastUpdatedCell.md new file mode 100644 index 0000000..ce823a5 --- /dev/null +++ b/src/components/LastUpdatedCell/LastUpdatedCell.md @@ -0,0 +1,38 @@ +A table cell that takes a time value and returns a relative time with a background color to indicate how old an item is. + +The LastUpdatedCell wraps an [ItemTableCell](/#/Components/ItemTableCell) + +## Default Usage +```jsx +import { Paper } from '@material-ui/core'; +import LastUpdatedCell from "./LastUpdatedCell"; + +let today = new Date(); +let threeDaysAgo = new Date().setDate(today.getDate() - 3); +let lastWeek = new Date().setDate(today.getDate() - 8); +let lastMonth = new Date().setDate(today.getDate() - 28); + + + { /* Today */ } + + { /* Three Days Ago */ } + + { /* Last Week */ } + + { /* Last Month */ } + + +``` + +```jsx static + + { /* Today */ } + + { /* Three Days Ago */ } + + { /* Last Week */ } + + { /* Last Month */ } + + +``` \ No newline at end of file diff --git a/src/components/LastUpdatedCell/index.js b/src/components/LastUpdatedCell/index.js new file mode 100644 index 0000000..35380d3 --- /dev/null +++ b/src/components/LastUpdatedCell/index.js @@ -0,0 +1,3 @@ +import LastUpdatedCell from "./LastUpdatedCell"; + +export default LastUpdatedCell; \ No newline at end of file diff --git a/src/components/QueueSelector/QueueSelector.js b/src/components/QueueSelector/QueueSelector.js index aa1a816..e39fb87 100644 --- a/src/components/QueueSelector/QueueSelector.js +++ b/src/components/QueueSelector/QueueSelector.js @@ -22,7 +22,7 @@ const getQueueCounts = async (access_token) => { myHeaders.append("Authorization", `Bearer ${access_token}`); let requestOptions = { headers: myHeaders }; - const apiResponse = await fetch(`/api/get_queues`, requestOptions); + const apiResponse = await fetch(`${process.env.PUBLIC_URL}/api/data/get_queues`, requestOptions); const queueCountJson = await apiResponse.json(); return queueCountJson; @@ -173,4 +173,4 @@ QueueSelector.propTypes = { "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/TimelineSkeleton/TimelineSkeleton.js b/src/components/TimelineSkeleton/TimelineSkeleton.js new file mode 100644 index 0000000..6c2d15d --- /dev/null +++ b/src/components/TimelineSkeleton/TimelineSkeleton.js @@ -0,0 +1,34 @@ +import React from "react"; +import { Paper, Typography, makeStyles, useTheme } from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; + +export default function TimelineSkeleton(){ + + const theme = useTheme(); + const useStyles = makeStyles({ + "Paper-root": { + overflow: "hidden" + }, + "padding": { + padding: theme.spacing(1) + } + + }); + const classes = useStyles(); + + return( + +
+ + + +
+
+ { + // Generate 2 placeholders. + [...Array(2).keys()].map( (_, index) => ) + } +
+
+ ); +} \ No newline at end of file diff --git a/src/components/TimelineSkeleton/TimelineSkeleton.md b/src/components/TimelineSkeleton/TimelineSkeleton.md new file mode 100644 index 0000000..c13a7fe --- /dev/null +++ b/src/components/TimelineSkeleton/TimelineSkeleton.md @@ -0,0 +1,17 @@ +Renders a skeleton UI to indicate loading of a timeline item. + +```jsx +import { ThemeProvider } from "@material-ui/core/styles"; +import webqueue2Theme from "../../theme"; +import TimelineActionCard from "./TimelineSkeleton"; + +const theme = webqueue2Theme(false); + + + + +``` + +```jsx static + +``` \ No newline at end of file diff --git a/src/components/TimelineSkeleton/index.js b/src/components/TimelineSkeleton/index.js new file mode 100644 index 0000000..0d8e1b3 --- /dev/null +++ b/src/components/TimelineSkeleton/index.js @@ -0,0 +1 @@ +export { default } from "./TimelineSkeleton"; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 799ad12..27c99fc 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,8 @@ import { CssBaseline } from '@material-ui/core'; import { BrowserRouter as Router } from 'react-router-dom'; import { CookiesProvider } from "react-cookie"; import AuthProvider from "./components/AuthProvider/"; +import ItemProvider from "./components/ItemProvider/"; + export const history = createBrowserHistory({ basename: process.env.PUBLIC_URL @@ -17,9 +19,11 @@ ReactDOM.render( - - - + + + + + , diff --git a/styleguidist/assetsDir/ItemBodyView_Loaded.png b/styleguidist/assetsDir/ItemBodyView_Loaded.png new file mode 100644 index 0000000..b99e9f5 Binary files /dev/null and b/styleguidist/assetsDir/ItemBodyView_Loaded.png differ diff --git a/styleguidist/assetsDir/ItemBodyView_Loading.gif b/styleguidist/assetsDir/ItemBodyView_Loading.gif new file mode 100644 index 0000000..3329413 Binary files /dev/null and b/styleguidist/assetsDir/ItemBodyView_Loading.gif differ diff --git a/styleguidist/styleguide.config.js b/styleguidist/styleguide.config.js index de44a92..12e4a94 100644 --- a/styleguidist/styleguide.config.js +++ b/styleguidist/styleguide.config.js @@ -6,6 +6,15 @@ const path = require('path') module.exports = { + + /** + * Static assets folder. Accessible as / in the style guide dev server. + * @type {string | array} + * @example "/" + * @default undefined + */ + assetsDir: "assetsDir", + /** * The title that appears at the top of the navigation bar. * @type {string} @@ -119,7 +128,7 @@ module.exports = { */ getComponentPathLine(componentPath) { const name = path.basename(componentPath, '.js') - return `import ${name} from './components/${name}/';` + return `import ${name} from '../components/${name}/';` }, /** diff --git a/utils/venv-manager.py b/utils/venv-manager.py index 72ecd3b..95e6eee 100644 --- a/utils/venv-manager.py +++ b/utils/venv-manager.py @@ -9,10 +9,28 @@ Reset a virtual environment: $ venv-manager.py reset + +Exit Codes: + 0 = Success + 5 = Failed to make directory VENV_DIR. It already exists. + 10 = Failed to make VENV_DIR. Incorrect permissions. + 15 = Failed to create virtual environment. See LOG FILE. + 20 = Failed read requirements file VENV_REQUIREMENTS_FILE. + 25 = Failed to install requirements. See LOG FILE. + 30 = VENV_INTERPRETER does not exist. + 35 = Failed to get pyldap release info from GitHub. + 40 = Failed to download pyldap source. See LOG FILE. + 45 = Failed to extract pyldap source. See LOG FILE. + 50 = Failed to read pyldap build config file. + 55 = Failed to write pyldap build config file. + 60 = Failed to build pyldap VERSION. See LOG FILE. + 65 = Failed to install pyldap VERSION. See LOG FILE. + 70 = Failed to delete VENV_DIR """ from pathlib import Path -import os, logging, argparse, subprocess +import os, logging, argparse, subprocess, urllib.request, json, configparser +from urllib.error import HTTPError, URLError from typing import Union @@ -27,6 +45,11 @@ VENV_DIR = Path(API_DIR, VENV_NAME) +# Set virtual evironment resource paths +VENV_INTERPRETER = Path(VENV_DIR, "bin", "python3") +VENV_REQUIREMENTS_FILE = Path(API_DIR, "requirements.txt") + + # Set minimum pip major version TARGET_PIP_VERSION = 19 @@ -36,21 +59,24 @@ logger = logging.getLogger(logger_name) logger.setLevel(logging.DEBUG) -# See: https://docs.python.org/3/library/logging.html#logrecord-attributes +# See Formatting Details: https://docs.python.org/3/library/logging.html#logrecord-attributes +# Example: Jan 28 2021 12:19:28 venv-manager : [INFO] Message log_message_format = "%(asctime)s %(name)s : [%(levelname)s] %(message)s" -# See: https://docs.python.org/3.6/library/time.html#time.strftime +# See Time Formatting Details: https://docs.python.org/3.6/library/time.html#time.strftime +# Example: Jan 28 2021 12:19:28 log_time_format = "%b %d %Y %H:%M:%S" log_formatter = logging.Formatter(log_message_format, log_time_format) +# Configure output to stdout stream_handler = logging.StreamHandler() stream_handler.setFormatter(log_formatter) stream_handler.setLevel(logging.INFO) +logger.addHandler(stream_handler) +# Configure out to logfile log_file_path = Path(WEBQUEUE2_DIR, "utils", f'{logger_name}.log') file_handler = logging.FileHandler(log_file_path) file_handler.setFormatter(log_formatter) - -logger.addHandler(stream_handler) logger.addHandler(file_handler) @@ -79,7 +105,7 @@ def get_args() -> argparse.Namespace: return parser.parse_args() -def run_logged_subprocess(command: Union[str, list], timeout: int = 10, shell: bool = True) -> tuple: +def run_logged_subprocess(command: Union[str, list], timeout: int = 60, shell: bool = True) -> tuple: """Executes a shell command using subprocess with logging. stderr is redirected to stdout and stdout is pipped to logger. @@ -96,7 +122,7 @@ def run_logged_subprocess(command: Union[str, list], timeout: int = 10, shell: b Args: command (Union): The command to run. If shell=False, pass a list with the first item being the command and the subsequent items being arguments. If shell=True, pass a string as you would type it into a shell. - timeout (int): The number of seconds to wait for a program before killing it + timeout (int): The number of seconds to wait for a program before killing it. Defaults to 60. Returns: tuple: With the first value being the return code and second being the combined stdout+stderr @@ -127,17 +153,224 @@ def run_logged_subprocess(command: Union[str, list], timeout: int = 10, shell: b finally: logger.debug(f"Exiting subprocess for '{command}'") return (logged_shell_process.returncode, process_output_stream) + + +def install_custom_pyldap(venv_interpreter: str) -> int: + """Builds python-ldap without SASL support from GitHub source. + + The version of python-ldap to be used is determined in the following order: + - Version from requirements.txt (can be beta) + - Latest non-beta version from GitHub + + Args: + venv_interpreter (str): The absolute path to the python interpreter executable for the virtual environment. + + Returns: + int: Exit code. + """ + logger.info("Starting pyldap build process") + + # Check for valid venv interpreter + logger.debug(f"Checking for valid venv interpreter at {venv_interpreter}") + if not os.path.exists(Path(venv_interpreter)): + logger.error(f"venv interpreter does not exist. Exiting") + exit(30) + logger.debug(f"venv interpreter is valid") + + # Get list of release tags for pyldap from GitHub API + logger.debug(f"Getting pyldap tags from GitHub") + pyldap_github_tags_url = "https://api.github.com/repos/python-ldap/python-ldap/tags" + try: + with urllib.request.urlopen(pyldap_github_tags_url) as request: + pyldap_tags = json.loads(request.read().decode("utf-8")) + except HTTPError as e: + logger.error(f"Failed to connect to {pyldap_github_tags_url}. Got response {e.code} {e.msg}. Exiting") + exit(35) + except URLError as e: + logger.error(f"Could not connect to {pyldap_github_tags_url}. {e.reason}. Exiting") + exit(35) + logger.debug(f"Got {len(pyldap_tags)} pyldap tags from GitHub") + + # Build dictionary of available pyldap releases and their source code archive urls + # Example: + # { "name": "python-ldap-3.3.1", "zipball_url": "http://github.com/download" } becomes + # { "3.3.1": "http://github.com/download" } + logger.debug("Building list of pyldap versions.") + pyldap_versions = {} + for tag in pyldap_tags: + tag_version = tag["name"].split("-")[-1] + zipball_url = f"https://github.com/python-ldap/python-ldap/archive/python-ldap-{tag_version}.zip" + pyldap_versions[tag_version] = zipball_url + logger.debug(f"Built list of {len(pyldap_versions)} pyldap versions.") + + # Check requirements file for pyldap version + pyldap_version_from_requirements = "" + logger.debug(f"Checking for pyldap version in requirements file {VENV_REQUIREMENTS_FILE}") + try: + with open(VENV_REQUIREMENTS_FILE) as requirements_file: + for line in requirements_file: + if line.startswith("pyldap"): + pyldap_version_from_requirements = line.split(" ")[-1].strip("\n") + logger.debug(f"Found pyldap version {pyldap_version_from_requirements} in requirements file") + break + except Exception as e: + logger.warning(f"Could not read requirements file {VENV_REQUIREMENTS_FILE}. {e.strerror}. Defaulting to latest non-beta release on GitHub.") + pass + + # Set pyldap version to value from requirements file if valid and available + if pyldap_version_from_requirements and pyldap_version_from_requirements in pyldap_versions.keys(): + logger.debug(f"pyldap version {pyldap_version_from_requirements} is available from GitHub") + pyldap_version = pyldap_version_from_requirements + logger.info(f"Set pyldap version to {pyldap_version} (from requirements file)") + # Set to latest non-beta version + else: + logger.warning(f"pyldap version not found in requirements file. Defaulting to latest non-beta release on GitHub") + for version in pyldap_versions.keys(): + is_beta_version = "b" in version + if (not is_beta_version): + pyldap_version = version + break + logger.info(f"Set pyldap version to {pyldap_version} (from GitHub releases)") + + # Download pyldap soure code + logger.info(f"Downloading pyldap {pyldap_version} source from {pyldap_versions[pyldap_version]}") + + tmp_dir = "/tmp" + download_file_name = f"python-ldap-{pyldap_version}.zip" + download_file_path = Path(tmp_dir, download_file_name) + + download_pyldap_returncode, _ = run_logged_subprocess(f"wget -q -O {download_file_path} {pyldap_versions[pyldap_version]}") + if download_pyldap_returncode == 0: + logger.debug(f"Downloaded pyldap {pyldap_version} source to {download_file_path}") + else: + logger.error(f"Failed to download pyldap source. See {log_file_path}. Exiting") + exit(40) + + # Extract source code + + # The archive from GitHub has a root folder formatted 'user-repo-version'. + # Because the pyldap source is user 'python-ldap' and repo 'python-ldap' + # the build folder MUST be the following format: + BUILD_DIR_NAME = f"python-ldap-python-ldap-{pyldap_version}" + BUILD_DIR_PATH = Path(tmp_dir, BUILD_DIR_NAME) + + logger.info(f"Extracing pyldap {pyldap_version} source to {BUILD_DIR_PATH}") + extract_source_returncode, _ = run_logged_subprocess(f"unzip -q -o -d {tmp_dir} {download_file_path}") + if extract_source_returncode == 0: + logger.debug(f"Extracted pyldap source to {BUILD_DIR_PATH}") + else: + logger.error(f"Failed to extract pyldap source. See {log_file_path}. Exiting") + exit(45) + + # Start the build process + logger.info(f"Building pyldap {pyldap_version}") + + # Read the pyldap build config file + pyldap_config_file_name = "setup.cfg" + pyldap_config_file_path = Path(BUILD_DIR_PATH, pyldap_config_file_name) + pyldap_version_from_needs_updated = True + + logger.debug(f"Reading pyldap build config file {pyldap_config_file_path}") + pyldap_config = configparser.ConfigParser() + try: + with open(pyldap_config_file_path) as pyldap_config_file: + pyldap_config.read_file(pyldap_config_file) + logger.debug("Read pyldap build config file") + except Exception as e: + logger.error(f"Failed to read pyldap build config file {pyldap_config_file_path}. {e}. Exiting") + exit(50) + + # Check for SASL requirement in pyldap build config file + logger.debug("Checking for '_ldap' section") + if not pyldap_config.has_section("_ldap"): + logger.warning("Failed to find '_ldap' section in pyldap build config file. pyldap may fail to build") + pyldap_version_from_needs_updated = False + pass + else: + logger.debug("'_ldap' section found") + + logger.debug("Checking for 'defines' option") + if not pyldap_config.has_option("_ldap", "defines"): + logging.warning("Failed to find 'defines' option in pyldap build config file. pyldap may fail to build") + pyldap_version_from_needs_updated = False + else: + logger.debug("'defines' option found") + + # Remove SASL requirement if present + if pyldap_version_from_needs_updated: + logger.debug("Removing SASL requirement") + + defines_options = pyldap_config['_ldap']['defines'].split(' ') + build_config_updated = False + try: + defines_options.remove('HAVE_SASL') + pyldap_config['_ldap']['defines'] = " ".join(defines_options) + logger.debug("SASL requirement removed") + build_config_updated = True + except ValueError as e: + logger.warning("SASL requirement not found in pyldap build config file. Build config file will not be modified") + pass + + # Write new build config + logger.debug("Writing new pyldap build config") + if build_config_updated: + try: + with open(pyldap_config_file_path, 'w') as pyldap_config_file: + pyldap_config.write(pyldap_config_file) + logger.debug("Wrote new pyldap build config") + except Exception as e: + logger.error(f"Failed to write pyldap build config file {pyldap_config_file_path}. {e}. Exiting") + exit(55) + + # Build pyldap + logger.debug(f"Building pyldap {pyldap_version}") + build_pyldap_returncode, _ = run_logged_subprocess(f"cd {BUILD_DIR_PATH} && {VENV_DIR}/bin/python3 setup.py build") + if build_pyldap_returncode == 0: + logger.debug(f"Built pyldap {pyldap_version}") + else: + logger.error(f"Failed to build pyldap {pyldap_version}. See {log_file_path}. Exiting") + exit(60) + + # Install pyldap + logger.debug(f"Installing pyldap {pyldap_version} in virtual environment {VENV_NAME} at {VENV_DIR}") + install_pyldap_returncode, _ = run_logged_subprocess(f"cd {BUILD_DIR_PATH} && {VENV_DIR}/bin/python3 setup.py install") + if install_pyldap_returncode == 0: + logger.debug(f"Installed pyldap {pyldap_version}") + else: + logger.error(f"Failed to install pyldap {pyldap_version}. See {log_file_path}. Exiting") + exit(65) + + logger.info(f"Finshed installing pyldap {pyldap_version}") + return 0 + + +def is_valid_requirement(line: str) -> bool: + """Determines if line is a valid requirement + + Args: + line (str): Line to check. + + Returns: + bool: True if line is valid requirement. False if line is not valid requirement. + """ + # Line is blank + if line == "\n" or line == "": + return False + + # Line is comment + if line.startswith("#"): + return False + + # Line is for pyldap + if line.startswith("pyldap"): + return False + + return True def create_environment() -> int: """Creates a virtual environment for webqueue2 - Exit Codes: - 0 = Success - 5 = VENV_DIR already exists - 10 = Could not create VENV_DIR - 15 = Could not install requirements - Returns: int: Exit code """ @@ -145,58 +378,101 @@ def create_environment() -> int: logger.info(f"Creating virtual environment {VENV_NAME} at {VENV_DIR}") # Check for an existing virtual environment + logger.debug(f"Creating virtual environment directory at {VENV_DIR}") try: os.mkdir(VENV_DIR) except FileExistsError: - logger.warning(f"The directory {VENV_DIR} already exists. Exiting") - return 5 + logger.error(f"Failed to make directory {VENV_DIR}. It already exists. Exiting") + exit(5) + except PermissionError: + logger.error(f"Failed to make directory {VENV_DIR}. Incorrect permissions. Exiting") + exit(10) + logger.debug(f"Created virtual environment directory") + - # Create virtual environmentc + # Create virtual environment + logger.debug(f"Creating virtual environment {VENV_NAME} at {VENV_DIR}") create_env_returncode, _ = run_logged_subprocess(f"cd {API_DIR} && python3 -m venv {VENV_NAME}", shell=True) if create_env_returncode == 0: - logger.info(f"Virtual environment {VENV_NAME} created at {VENV_DIR}") + logger.info(f"Successfully created virtual environment {VENV_NAME} created at {VENV_DIR}") else: - logger.critical(f"Could not create virtual environment {VENV_NAME} at {VENV_DIR}. Exiting") - return 10 + logger.error(f"Failed to create virtual environment. See {log_file_path}. Exiting") + exit(15) # Check pip version logger.debug("Checking pip version") check_pip_returncode, check_pip_output = run_logged_subprocess(f"{VENV_DIR}/bin/pip --version") - - if check_pip_returncode != 0: - logger.warning("Could not check pip version. Virtual environment dependencies may not install") - - pip_version_full = check_pip_output.split()[1] - logger.debug(f"pip version is {pip_version_full}") + if check_pip_returncode == 0: + pip_version_full = check_pip_output.split()[1] + logger.debug(f"pip version is {pip_version_full}") + else: + logger.warning("Failed to check pip version. Virtual environment dependencies may not install") + # Update pip if needed pip_version_major = pip_version_full.split(".")[0] if int(pip_version_major) < 19: - logger.info(f"pip verion is {pip_version_major}.x (pip >= {TARGET_PIP_VERSION}.x needed.) Upgrading pip") + logger.debug(f"pip verion is {pip_version_major}.x (pip >= {TARGET_PIP_VERSION}.x needed.) Upgrading pip") update_pip_returncode, update_pip_output = run_logged_subprocess(f"{VENV_DIR}/bin/pip install --upgrade pip") - if update_pip_returncode == 0: - logger.info(update_pip_output.split("\n")[-2]) + pip_install_message = update_pip_output.split("\n")[-2] + logger.debug(pip_install_message) else: logger.warning("Failed to update pip. Virtual environment dependencies may not install") + + # Install requirements + logger.info("Installing virtual environment requirements") + + # Install python-ldap + install_custom_pyldap(VENV_INTERPRETER) + # Install the rest of the requirements from the requirements file + logger.debug(f"Checking for venv requirements file {VENV_REQUIREMENTS_FILE}") + if not os.path.exists(VENV_REQUIREMENTS_FILE): + logger.warning(f"Could not find requirements file {VENV_REQUIREMENTS_FILE}. No requirements will be installed") + return 0 + logger.debug("Found requirements file") + + # Get raw requirements from requirements file + logger.debug(f"Reading raw requirements from requirements file {VENV_REQUIREMENTS_FILE}") + try: + with open(VENV_REQUIREMENTS_FILE) as requirements_file: + raw_requirements = requirements_file.readlines() + except Exception as e: + logger.warning(f"Failed read requirements file {VENV_REQUIREMENTS_FILE}. {e}. Exiting") + exit(20) + logger.debug("Read raw requirements from requirements file") + + # Filter and clean requirements + logger.debug("Validating requirements") + valid_requirements = [] + for requirement in raw_requirements: + if is_valid_requirement(requirement): + valid_requirements.append(requirement.strip()) + logger.debug(f"Validated {len(valid_requirements)} requirements") + + # Generate requirements string + logger.debug("Generating requirements string") + requirements_string = "" + for requirement in valid_requirements: + requirements_string += f"'{requirement}' " + logger.debug(f"Generated requirements string {requirements_string}") + # Install requirements - logger.info("Installing requirements") - install_requirements_returncode, _ = run_logged_subprocess(f"{VENV_DIR}/bin/pip install -r {API_DIR}/requirements.txt") + logger.debug("Installing requirements") + install_requirements_returncode, _ = run_logged_subprocess(f"{VENV_DIR}/bin/pip install {requirements_string}") if install_requirements_returncode == 0: logger.info("Successfully installed requirements") - return 0 else: - logger.critical("Failed to install requirements. Exiting") - return 15 + logger.error(f"Failed to install requirements. See {log_file_path}. Exiting") + exit(25) + + logger.info("Finished creating virtual environment") + return 0 def delete_environment() -> int: """Deletes a virtual environment for webqueue2 - Exit Codes: - 0 = Success - 5 = Could not delete VENV_DIR - Returns: int: Exit code """ @@ -207,33 +483,21 @@ def delete_environment() -> int: logger.info(f"Successfully deleted virtual environment {VENV_NAME} at {VENV_DIR}") return 0 else: - logger.critical(f"Failed to delete virtual environment {VENV_NAME} at {VENV_DIR}. Exiting") - return 5 + logger.error(f"Failed to delete virtual environment {VENV_NAME} at {VENV_DIR}. Exiting") + exit(70) + def reset_environment() -> int: """Resets a virtual environment for webqueue2 - Exit Codes: - 0 = Success - 5 = Could not delete VENV_DIR - 10 = Could not create VENV_DIR - Returns: int: Exit code """ logger.info(f"Resetting virtual environment {VENV_NAME} at {VENV_DIR}") - delete_returncode = delete_environment() - if delete_returncode != 0: - logger.critical(f"Failed to reset virtual environment {VENV_NAME} at {VENV_DIR}. Exiting") - return 5 - create_returncode = create_environment() - if create_returncode != 0: - logger.critical(f"Failed to reset virtual environment {VENV_NAME} at {VENV_DIR}. Exiting") - return 10 - logger.info(f"Successfully reset virtual environment {VENV_NAME} at {VENV_DIR}. Exiting") + return 0 if __name__ == "__main__": @@ -252,4 +516,4 @@ def reset_environment() -> int: elif action == "reset": exit(reset_environment()) else: - logger.critical(f'Invalid argument {action}') \ No newline at end of file + logger.critical(f'Invalid argument {action}')