diff --git a/.gitignore b/.gitignore index c518367..4151e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,15 +8,17 @@ # Testing /coverage -# Productiom +# React Build Files /build -# Misc -.DS_Store +# React local environment files .env.local .env.development.local .env.test.local .env.production.local + +# Misc +.DS_Store .vscode/ # Node Package Management @@ -28,3 +30,5 @@ yarn-error.log* /api/venv __pycache__/ venv-manager.log +/api/.env +*.egg* diff --git a/Dev Environment Setup Guide.md b/Dev Environment Setup Guide.md index e80c78b..03ed436 100644 --- a/Dev Environment Setup Guide.md +++ b/Dev Environment Setup Guide.md @@ -252,4 +252,5 @@ All of the tools in this project are accessible as an npm task so you can intera | `kill:api` | Kills the runaway API process(es). | | `venv:create` | This will create a virtual environment in `/api/venv` and install requirements from `/api/requirements.txt`. | | `venv:delete` | This will delete the folder `/api/venv`. | -| `venv:reset` | This will run `venv:delete` then `venv:create`. | \ No newline at end of file +| `venv:reset` | This will run `venv:delete` then `venv:create`. | +| `venv:freeze` | Regenerates the API requirements.txt file and mitigates [this pip bug](https://github.com/pypa/pip/issues/4022). | diff --git a/README.md b/README.md index 36e6329..0b31b4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # webqueue2 A re-write of Purdue ECN's webqueue -![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-09-22%20at%201.48.58%20PM.png) +![UI Snapshot](./docs/UI%20Snapshots/UI-Snapshot%202020-12-03%20at%208.10.32%20PM.png) ## Stay Up To Date See what's being worked on with [the webqueue2 Project](https://github.itap.purdue.edu/ECN/webqueue2/projects/). diff --git a/api/ECNQueue.py b/api/ECNQueue.py index e0b9d77..96e1078 100644 --- a/api/ECNQueue.py +++ b/api/ECNQueue.py @@ -203,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) @@ -219,8 +219,22 @@ def __parseHeaders(self) -> list: message = email.message_from_string(headerString) headers = [] + dateHeaders=[ + "QStatus-Updated-Time", + "Status-Updated-Time", + "Edited-Time", + "QTime-Updated-Time", + "Merged-Time", + "Time-Updated-Time", + "Replied-Time", + "Assigned-To-Updated-Time", + "QAssigned-To-Updated-Time", + "Date", + "Sent" + ] + for key in message.keys(): - headers.append({"type": key, "content": message[key]}) + headers.append({"type": key, "content": self.__getFormattedDate(message[key]) if key in dateHeaders else message[key]}) return headers @@ -240,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"): @@ -892,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 @@ -1167,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: @@ -1322,7 +1358,11 @@ def getQueueCounts() -> list: possibleItems = os.listdir(queueDirectory + "/" + queue) validItems = [isValidItemName for file in possibleItems] 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']) + + return sortedQueueInfo def loadAllQueues(headersOnly: bool = True) -> list: """Return a list of Queues for each queue. diff --git a/api/api.py b/api/api.py index f84cb9d..1426d8a 100644 --- a/api/api.py +++ b/api/api.py @@ -1,7 +1,20 @@ -from flask import Flask, request +from flask import Flask, request, after_this_request from flask_restful import Api, Resource +from flask_jwt_extended import ( + JWTManager, create_access_token, create_refresh_token, + jwt_required, get_jwt_identity, jwt_refresh_token_required, + set_refresh_cookies, unset_refresh_cookies +) +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 +dotenv.load_dotenv() + # Create Flask App app = Flask(__name__) @@ -9,11 +22,145 @@ api = Api(app) +################################################################################ +# Configure Flask-JWT-Extended +################################################################################ + +# Set JWT secret key and create JWT manager +app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY") +# Set identity claim field key to sub for JWT RFC complience +# Flask-JWT-Extended uses 'identity' by default for compatibility reasons +app.config["JWT_IDENTITY_CLAIM"] = "sub" +# Set the key for error messages generated by Flask-JWT-Extended +app.config["JWT_ERROR_MESSAGE_KEY"] = "message" + +# Look for JWTs in headers (for access) then cookies (for refresh) +app.config["JWT_TOKEN_LOCATION"] = ["headers", "cookies"] +# Restrict cookies to HTTPS in prod, allow HTTP in dev +app.config["JWT_COOKIE_SECURE"] = False if os.environ.get("ENVIRONMENT") == "dev" else True +# Restrict cookies using SameSite=strict flag +app.config["JWT_COOKIE_SAMESITE"] = "strict" +# Restrict refresh tokens to /token/refresh endpoint +app.config["JWT_REFRESH_COOKIE_PATH"] = '/tokens/refresh' +# Set the cookie key for CRSF validation string +# This is the default value. Adding it for easy reference +app.config["JWT_REFRESH_CSRF_HEADER_NAME"] = "X-CSRF-TOKEN" + +tokenManager = JWTManager(app) + + + +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. + + Return Codes: + 200 (OK): On success. + 401 (Unauthroized): When username or password are incorrect. + 422 (Unprocessable Entitiy): When the username or password can't be parsed. + + Example: + curl -X POST + -H "Content-Type: application/json" + -d '{"username": "bob", "password": "super_secret"}' + + { "access_token": fjr09hfp09h932jp9ruj3.3r8ihf8h0w8hr08ifhj804h8i.8h48ith08ity409hip0t4 } + + Returns: + tuple: Response containing tokens and HTTP response code. + """ + if not request.is_json: + return ({ "message": "JSON missing from request body"}, 422) + + data = request.json + + fields_to_check = ["username", "password"] + for field in fields_to_check: + if field not in data.keys(): + return ({ "message": f"{field} missing from request body"}, 422) + + if 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"]) + + # This decorator is needed because Flask-RESTful's 'resourceful routing` + # doesn't allow for direct modification to the Flask response object. + # See: https://flask-restful.readthedocs.io/en/latest/quickstart.html#resourceful-routing + @after_this_request + def _does_this_work(response): + set_refresh_cookies(response, refresh_token) + return response + + return ({ "access_token": access_token }, 200) + +class RefreshAccessToken(Resource): + @jwt_refresh_token_required + def post(self): + username = get_jwt_identity() + access_token = create_access_token(username) + return ({"access_token": access_token}, 200) class Item(Resource): - def get(self, queue: str, number: int) -> str: + @jwt_required + def get(self, queue: str, number: int) -> tuple: """Returns the JSON representation of the item requested. + Return Codes: + 200 (OK): On success. + Example: { "lastUpdated": "07-23-20 10:11 PM", @@ -37,14 +184,15 @@ def get(self, queue: str, number: int) -> str: item (int): The number of the item requested. Returns: - str: JSON representation of the item requested. + tuple: Item as JSON and HTTP response code. """ headersOnly = True if request.args.get("headersOnly") == "True" else False return ECNQueue.Item(queue, number, headersOnly=headersOnly).toJson() class Queue(Resource): - def get(self, queue: str) -> str: + @jwt_required + def get(self, queues: str) -> tuple: """Returns the JSON representation of the queue requested. Example: @@ -52,25 +200,32 @@ def get(self, queue: str) -> str: "name": ce, "items": [...] } + + Return Codes: + 200 (OK): On success. Args: - queue (str): The queue requested. + queues (str): Plus (+) deliminited list of queues. Returns: - str: JSON representation of the queue requested. + tuple: Queues as JSON and HTTP response code. """ headersOnly = False if request.args.get("headersOnly") == "False" else True queues_requested = queue.split("+") - queues = [] + queue_list = [] for queue in queues_requested: - queues.append(ECNQueue.Queue(queue, headersOnly=headersOnly).toJson()) - return queues + queue_list.append(ECNQueue.Queue(queue, headersOnly=headersOnly).toJson()) + return (queue_list, 200) class QueueList(Resource): - def get(self) -> list: + @jwt_required + def get(self) -> tuple: """Returns a list of dictionaries with the number of items in each queue. + Return Codes: + 200 (OK): On success. + Example: [ { @@ -84,17 +239,15 @@ def get(self) -> list: ] Returns: - list: Dictionaries with the number of items in each queue. + tuple: Queues and item counts as JSON and HTTP response code. """ - return ECNQueue.getQueueCounts() + return (ECNQueue.getQueueCounts(), 200) - - -api.add_resource(QueueList, "/api/get_queues") +api.add_resource(Login, "/login") +api.add_resource(RefreshAccessToken, "/tokens/refresh") api.add_resource(Item, "/api//") -api.add_resource(Queue, "/api/") - - +api.add_resource(Queue, "/api/") +api.add_resource(QueueList, "/api/get_queues") if __name__ == "__main__": app.run() \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt index 8a009e1..fcd08ee 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,20 +1,25 @@ -aniso8601==8.0.0 -astroid==2.4.2 -click==7.1.2 -Flask==1.1.2 -Flask-RESTful==0.3.8 -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 -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.* +# Specify pyldap version for custom build. This is not installed by pip. +pyldap == 3.3.1 +easyad + +# API Documentation +mkdocs +mkdocs-material +mkautodoc \ No newline at end of file diff --git a/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png b/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png new file mode 100644 index 0000000..eb6d9e6 Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-11-13 at 1.48.58 PM.png differ diff --git a/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png b/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png new file mode 100644 index 0000000..cb06397 Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-11-23 at 8.57.36 AM.png differ diff --git a/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png b/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png new file mode 100644 index 0000000..aa66e5d Binary files /dev/null and b/docs/UI Snapshots/UI-Snapshot 2020-12-03 at 8.10.32 PM.png differ diff --git a/package-lock.json b/package-lock.json index 0910693..b918c04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1840,6 +1840,11 @@ "@babel/types": "^7.3.0" } }, + "@types/cookie": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", + "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -1854,6 +1859,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", @@ -9040,6 +9054,11 @@ "object.assign": "^4.1.0" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -12065,6 +12084,16 @@ "use-memo-one": "^1.1.1" } }, + "react-cookie": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.0.3.tgz", + "integrity": "sha512-cmi6IpdVgTSvjqssqIEvo779Gfqc4uPGHRrKMEdHcqkmGtPmxolGfsyKj95bhdLEKqMdbX8MLBCwezlnhkHK0g==", + "requires": { + "@types/hoist-non-react-statics": "^3.0.1", + "hoist-non-react-statics": "^3.0.0", + "universal-cookie": "^4.0.0" + } + }, "react-dev-utils": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.2.1.tgz", @@ -15762,6 +15791,15 @@ "unist-util-is": "^3.0.0" } }, + "universal-cookie": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", + "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", + "requires": { + "@types/cookie": "^0.3.3", + "cookie": "^0.4.0" + } + }, "universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", diff --git a/package.json b/package.json index 56190c6..8b496df 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "@testing-library/user-event": "^7.2.1", "clsx": "^1.1.1", "history": "^5.0.0", + "jwt-decode": "^3.1.2", "material-table": "^1.63.1", "react": "^16.13.1", + "react-cookie": "^4.0.3", "react-dom": "^16.13.1", "react-relative-time": "0.0.7", + "react-router-dom": "^5.2.0", "react-scripts": "3.4.1", - "react-table": "^7.5.1", - "react-router-dom": "^5.2.0" + "react-table": "^7.5.1" }, "scripts": { "start:frontend": "react-scripts start", @@ -32,6 +34,7 @@ "venv:create": "python3 utils/venv-manager.py create", "venv:delete": "python3 utils/venv-manager.py delete", "venv:reset": "python3 utils/venv-manager.py reset", + "venv:freeze": "cd api/ && venv/bin/pip freeze | grep -v 'pkg-resources' > requirements.txt", "test": "react-scripts test", "eject": "react-scripts eject" }, diff --git a/src/App.js b/src/App.js index 8320c96..c410707 100644 --- a/src/App.js +++ b/src/App.js @@ -1,110 +1,26 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { ThemeProvider } from "@material-ui/core/styles"; -import { Box, makeStyles, Paper } from "@material-ui/core"; -import { Route } from "react-router-dom"; -import clsx from "clsx"; import webqueueTheme from "./theme"; -import ItemTableAppBar from "./components/ItemTableAppBar/"; -import ItemTable from "./components/ItemTable/"; -import ItemViewAppBar from "./components/ItemViewAppBar/"; -import ItemView from "./components/ItemView/"; +import { Switch, Route } from "react-router-dom"; +import PrivateRoute from "./components/PrivateRoute/"; +import AppView from "./components/AppView/"; +import LoginForm from "./components/LoginForm/"; function App() { const [darkMode, setDarkMode] = useState(false); - const [activeItem, setActiveItem] = useState({}); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [queues, setQueues] = useState([]); - const [items, setItems] = useState([]); - - 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"], { - duration: theme.transitions.duration.enteringScreen, - easing: theme.transitions.easing.easeInOut - }); - const useStyles = makeStyles({ - "leftCol": { - overflow: "auto", - width: "100vw", - height: "100vh", - transition: transitionWidth, - }, - "rightCol": { - overflow: "auto", - width: "0", - height: "100vh", - transition: transitionWidth, - scrollbarWidth: 0, - }, - "rightColShift": { - overflowY: "auto", - width: "100vw", - flexShrink: "0", - transition: transitionWidth - }, - [theme.breakpoints.up("md")]: { - "rightColShift": { - width: "40vw", - } - }, - }); - const classes = useStyles(); - - return ( + return ( - - - - - console.log("Clicked!") }/> - - - - {items.length === 0 ? null : - { - const item = items.find((item) => { - return item.queue === match.params.queue && item.number === Number(match.params.number); - }); - - if (item === undefined) { - return ( - - ); - } - - setActiveItem(item); - - return ( - <> - - - - ); - } - } - /> - } - - + + + + + + + + ); } diff --git a/src/auth/index.js b/src/auth/index.js new file mode 100644 index 0000000..679b398 --- /dev/null +++ b/src/auth/index.js @@ -0,0 +1 @@ +export { login, refresh } from "./utilities"; \ No newline at end of file diff --git a/src/auth/utilities.js b/src/auth/utilities.js new file mode 100644 index 0000000..8800954 --- /dev/null +++ b/src/auth/utilities.js @@ -0,0 +1,59 @@ +/** Utility Functions for webqueue2 API */ + + + +/** + * Returns an access token to be used for authorization. + * @example + * login("janeDoe", "superSecretPassword") + * @param {String} username + * @param {String} password + * @returns {Boolean | String} An access token on success, `false` otherwise. + */ +export async function login(username, password){ + const loginInit = { + method: "POST", + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ "username": username, "password": password}) + }; + + let loginResponse = await fetch("/login", loginInit); + let data = await loginResponse.json(); + + if (data === null){ + return false; + } + if (!loginResponse.ok){ + console.error(`Login failed. Got code ${loginResponse.status} (${loginResponse.statusText})`); + return false; + } + + return data.access_token || false; +} + +/** + * Refresh current access token. + * @example + * refresh("csrf_refresh_token") + * @param {String} csrf_refresh_token The current CSRF validation string. + * @returns {Boolean | String} An access token on success, `false` otherwise. + */ +export async function refresh(csrf_refresh_token){ + const refreshInit = { + method: "POST", + headers: {'X-CSRF-TOKEN': csrf_refresh_token}, + }; + + let refreshResponse = await fetch("/tokens/refresh", refreshInit); + let data = await refreshResponse.json(); + + if (data === null){ + return false; + } + if (!refreshResponse.ok){ + console.error(`Refresh failed. Got code ${refreshResponse.status} (${refreshResponse.statusText})`); + return false; + } + + return data.access_token || false; +} \ No newline at end of file diff --git a/src/components/AppView/AppView.js b/src/components/AppView/AppView.js new file mode 100644 index 0000000..75c7c60 --- /dev/null +++ b/src/components/AppView/AppView.js @@ -0,0 +1,143 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Box, makeStyles, Paper, useTheme } from "@material-ui/core"; +import { Route } from "react-router-dom"; +import clsx from "clsx"; +import ItemTableAppBar from "../ItemTableAppBar/"; +import ItemTable from "../ItemTable/"; +import ItemViewAppBar from "../ItemViewAppBar/"; +import ItemView from "../ItemView/"; +import QueueSelector from "../QueueSelector/"; +import { useToken } from "../AuthProvider/"; + +export default function AppView({ setDarkMode }){ + const [activeItem, setActiveItem] = useState({}); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [queues, setQueues] = useState([]); + const [items, setItems] = useState([]); + const [selectedQueues, setSelectedQueues] = useState([]); + const [queueSelectorOpen, setQueueSelectorOpen] = useState(false); + + const access_token = useToken(); + + useEffect( _ => { + async function getQueues(){ + if (access_token === null){ + return undefined + } + + if (queueSelectorOpen){ + return undefined + } + + if (selectedQueues.length > 0){ + let queuesToLoad = ""; + + for (let selectedQueue of selectedQueues){ + queuesToLoad += `+${selectedQueue.name}`; + } + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`/api/${queuesToLoad}`, requestOptions); + const queueJson = await apiResponse.json(); + setQueues(queueJson); + } else { + setQueues([]) + } + } + getQueues(); + }, [selectedQueues, access_token, queueSelectorOpen]); + + useEffect( _ => { + let tempItems = []; + for (let queue of queues){ + tempItems = tempItems.concat(queue.items); + } + setItems(tempItems); + }, [queues]); + + const theme = useTheme(); + const transitionWidth = theme.transitions.create(["width"], { + duration: theme.transitions.duration.enteringScreen, + easing: theme.transitions.easing.easeInOut + }); + const useStyles = makeStyles({ + "leftCol": { + overflow: "auto", + width: "100vw", + height: "100vh", + transition: transitionWidth, + }, + "rightCol": { + overflow: "auto", + width: "0", + height: "100vh", + transition: transitionWidth, + scrollbarWidth: 0, + }, + "rightColShift": { + overflowY: "auto", + width: "100vw", + flexShrink: "0", + transition: transitionWidth + }, + [theme.breakpoints.up("md")]: { + "rightColShift": { + width: "40vw", + } + }, + }); + const classes = useStyles(); + + return( + + + + + + + + + + {items.length === 0 ? null : + { + const item = items.find((item) => { + return item.queue === match.params.queue && item.number === Number(match.params.number); + }); + + if (item === undefined) { + return ( + + ); + } + + setActiveItem(item); + + return ( + <> + + + + ); + } + } + /> + } + + + ); +}; + +AppView.propTypes = {}; + +AppView.defaultProps = {}; \ No newline at end of file diff --git a/src/components/AppView/AppView.md b/src/components/AppView/AppView.md new file mode 100644 index 0000000..9d4107e --- /dev/null +++ b/src/components/AppView/AppView.md @@ -0,0 +1,10 @@ +The primary view for webqueue2. + +--- +```jsx +import AppView from "./AppView"; + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/AppView/index.js b/src/components/AppView/index.js new file mode 100644 index 0000000..08cc8fb --- /dev/null +++ b/src/components/AppView/index.js @@ -0,0 +1 @@ +export { default } from "./AppView"; \ No newline at end of file diff --git a/src/components/AuthProvider/AuthProvider.js b/src/components/AuthProvider/AuthProvider.js new file mode 100644 index 0000000..ae0effe --- /dev/null +++ b/src/components/AuthProvider/AuthProvider.js @@ -0,0 +1,78 @@ +import React, { useState, createContext, useContext, useEffect } from "react"; +import { useCookies } from "react-cookie"; +import { refresh } from "../../auth/"; +import decodeJWT from "jwt-decode"; + + + +const LoginContext = createContext(); +const LoginSetterContext = createContext(); +const TokenContext = createContext(); +const TokenSetterContext = createContext(); + +export const useLogin = () => useContext(LoginContext); +export const useLoginSetter = () => useContext(LoginSetterContext); +export const useToken = () => useContext(TokenContext); +export const useTokenSetter = () => useContext(TokenSetterContext); + + + +export default function AuthProvider({ children }) { + const [loggedIn, setLoggedIn] = useState( false ); + const [token, setToken] = useState( null ); + + const [cookies] = useCookies(["csrf_refresh_token"]); + + async function tryRefresh(csrf_refresh_token){ + if (csrf_refresh_token === undefined){ + return false; + } + + const new_access_token = await refresh(csrf_refresh_token); + if (!new_access_token){ + console.error("Failed to refresh access token.") + return false; + } + + setToken(new_access_token); + setLoggedIn(true); + } + + // Attempt to refresh token on page load + useEffect( _ => { + (async () => { + await tryRefresh(cookies.csrf_refresh_token); + })(); + }, [cookies]); + + // Auto update token + useEffect( () => { + if (token === null) { + return undefined; + } + + // 5 second buffer for access token refresh + const refersh_buffer_time = 5000; + const access_token_expiration_claim = decodeJWT(token).exp + const access_token_expiration_time = new Date(0).setUTCSeconds(access_token_expiration_claim); + const miliseconds_to_access_token_expiration = (access_token_expiration_time - Date.now()) + + const timer = setTimeout( async () => { + await tryRefresh(cookies.csrf_refresh_token); + }, miliseconds_to_access_token_expiration - refersh_buffer_time); + + return () => clearTimeout(timer); + }, [token, cookies]); + + return ( + + + + + {children} + + + + + ); +}; \ No newline at end of file diff --git a/src/components/AuthProvider/AuthProvider.md b/src/components/AuthProvider/AuthProvider.md new file mode 100644 index 0000000..aad5bee --- /dev/null +++ b/src/components/AuthProvider/AuthProvider.md @@ -0,0 +1,14 @@ +AuthProvider + +Description + +--- + +```jsx +import AuthProvider from "./AuthProvider"; + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/AuthProvider/index.js b/src/components/AuthProvider/index.js new file mode 100644 index 0000000..165e516 --- /dev/null +++ b/src/components/AuthProvider/index.js @@ -0,0 +1 @@ +export { default, useLogin, useLoginSetter, useToken, useTokenSetter } from "./AuthProvider"; \ No newline at end of file diff --git a/src/components/ItemBodyView/ItemBodyView.js b/src/components/ItemBodyView/ItemBodyView.js index 4e5edce..08a57f9 100644 --- a/src/components/ItemBodyView/ItemBodyView.js +++ b/src/components/ItemBodyView/ItemBodyView.js @@ -13,8 +13,9 @@ export default function ItemBodyView({ item }) { const useStyles = makeStyles(() => ({ "Timeline-root": { - paddingLeft: "0", - paddingRight: "0", + padding: "0", + marginTop: "0", + marginBottom: "0", }, "TimelineContent-root": { paddingRight: "0", diff --git a/src/components/ItemHeaderView/ItemHeaderView.js b/src/components/ItemHeaderView/ItemHeaderView.js new file mode 100644 index 0000000..798a3fb --- /dev/null +++ b/src/components/ItemHeaderView/ItemHeaderView.js @@ -0,0 +1,93 @@ +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { useTable, useFlexLayout, useFilters } from "react-table"; +import { TableContainer, Table, TableHead, TableRow, TableCell, TableBody, TextField, useTheme, makeStyles } from "@material-ui/core"; + +export default function ItemHeaderView({ data }) { + + const theme = useTheme(); + const useStyles = makeStyles({ + HeaderCell_root: { + paddingBottom: theme.spacing(2), + borderBottomWidth: 0 + }, + ContentCell_root: { + wordBreak: "break-word" + }, + bandedRows: { + '&:nth-of-type(even)': { + backgroundColor: theme.palette.type === 'light' ? theme.palette.grey[50] : theme.palette.grey[700], + } + } + }); + const classes = useStyles(); + + const columns = useMemo(() => [ + { Header: 'Type', accessor: 'type', Cell: ({ value }) => {value} , width: 1 }, + { Header: 'Content', accessor: 'content', width: 2 } + ], []); + + const defaultColumn = { + Filter: ({ column: { Header, setFilter } }) => ( + setFilter(event.target.value) } + type="search" + size="small" + variant="outlined" + color="secondary" + fullWidth + /> + ) + } + + const tableInstance = useTable({ columns, data, defaultColumn }, useFlexLayout, useFilters); + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow + } = tableInstance; + + return ( + + + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => ( + + {column.render('Filter')} + + ))} + + ))} + + + {rows.map( (row) => { + prepareRow(row); + return ( + + {row.cells.map( (cell) => ( + + {cell.render("Cell")} + + ))} + + ); + })} + +
+
+ ); +}; + +ItemHeaderView.propTypes = { + /** An array of object containing header type and content. */ + "data": PropTypes.array +}; + +ItemHeaderView.defaultProps = { + "data": [] +} \ No newline at end of file diff --git a/src/components/ItemHeaderView/ItemHeaderView.md b/src/components/ItemHeaderView/ItemHeaderView.md new file mode 100644 index 0000000..e69de29 diff --git a/src/components/ItemHeaderView/index.js b/src/components/ItemHeaderView/index.js new file mode 100644 index 0000000..f47b29b --- /dev/null +++ b/src/components/ItemHeaderView/index.js @@ -0,0 +1 @@ +export { default } from "./ItemHeaderView"; \ No newline at end of file diff --git a/src/components/ItemTable/ItemTable.js b/src/components/ItemTable/ItemTable.js index 2519a04..497296d 100644 --- a/src/components/ItemTable/ItemTable.js +++ b/src/components/ItemTable/ItemTable.js @@ -1,26 +1,28 @@ -import React from "react"; +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, Paper, Grid, ButtonGroup, IconButton, makeStyles, useTheme } from "@material-ui/core"; import { useHistory } from "react-router-dom"; import RelativeTime from "react-relative-time"; import ItemTableFilter from "../ItemTableFilter/" import { ArrowDownward, ArrowUpward } from "@material-ui/icons"; +import ItemTableCell from "../ItemTableCell"; +import LastUpdatedCell from "../LastUpdatedCell/"; -export default function ItemTable({ data }) { +export default function ItemTable({ data, rowCanBeSelected }) { + const [selectedRow, setSelectedRow] = useState({ queue: null, number: null }); const theme = useTheme(); const useStyles = makeStyles({ - // Fully visible for active icons - activeSortIcon: { - opacity: 1, + 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`, + }, }, - // Half visible for inactive icons - inactiveSortIcon: { - opacity: 0.2, + rowSelected: { + backgroundColor: theme.palette.type === 'light' ? theme.palette.primary[100] : theme.palette.primary[600], }, bandedRows: { '&:nth-of-type(even)': { @@ -31,30 +33,34 @@ export default function ItemTable({ data }) { borderLeftWidth: "1px", borderLeftStyle: "solid", borderColor: theme.palette.type === "light" ? theme.palette.grey[300] : theme.palette.grey[500] - } + }, }); 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 ( @@ -63,16 +69,27 @@ export default function ItemTable({ data }) { 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 => ( @@ -119,18 +136,50 @@ export default function ItemTable({ data }) { ))} - {rows.map((row, i) => { + {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}`)} - className={classes.bandedRows} - {...row.getRowProps()} > + hover + onClick={() => { + 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("Cell")} - - ))} + cell.render(_ => { + switch (cell.column.id) { + case "dateReceived": + return ( + + + + ); + case "lastUpdated": + return ( + + ); + default: + return ( + + {cell.value} + + ); + } + }) + ))}; ); })} @@ -142,9 +191,14 @@ export default function ItemTable({ data }) { ItemTable.propTypes = { /** Array of items from all active queues to display in table. */ - "items": PropTypes.array + "items": PropTypes.array, + /** State variable indicating if rows can be selected. When false, all rows are deselected. */ + "rowCanBeSelected": PropTypes.bool }; ItemTable.defaultProps = { - "items": [] -}; \ No newline at end of file + /** The items to display in the table. */ + "items": [], + /** A state variable determining whether a row can be selected or not. */ + "rowCanBeSelected": true +}; diff --git a/src/components/ItemTableCell/ItemTableCell.js b/src/components/ItemTableCell/ItemTableCell.js new file mode 100644 index 0000000..cf13a74 --- /dev/null +++ b/src/components/ItemTableCell/ItemTableCell.js @@ -0,0 +1,37 @@ +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.object, + /** 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 68f9d56..c3ef252 100644 --- a/src/components/ItemTableFilter/ItemTableFilter.js +++ b/src/components/ItemTableFilter/ItemTableFilter.js @@ -1,23 +1,36 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { TextField } from "@material-ui/core"; +import { Box, FormControl, InputLabel, makeStyles, OutlinedInput,} from "@material-ui/core"; export default function ItemTableFilter({ label, onChange }) { + const useStyles = makeStyles({ + filterContainer: { + overflowX: "hidden" + }, + }); + const classes = useStyles(); + // 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 ( - <> - + - + > + {label} + + + ); - }; ItemTableFilter.propTypes = { @@ -29,4 +42,4 @@ ItemTableFilter.propTypes = { ItemTableFilter.defaultProps = { "label": "" -} \ No newline at end of file +} diff --git a/src/components/ItemView/ItemView.js b/src/components/ItemView/ItemView.js index 5ec157a..8bd7eda 100644 --- a/src/components/ItemView/ItemView.js +++ b/src/components/ItemView/ItemView.js @@ -1,10 +1,15 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from "prop-types"; -import { Paper, makeStyles, useTheme } from '@material-ui/core'; +import { Paper, AppBar, Tab, makeStyles, useTheme } from '@material-ui/core'; +// Import these tab components from @material-ui/lab instead of @material-ui/core for automatic a11y props +// See: https://material-ui.com/components/tabs/#experimental-api +import { TabContext, TabList, TabPanel } from '@material-ui/lab'; import ItemMetadataView from "../ItemMetadataView/" import ItemBodyView from "../ItemBodyView"; +import ItemHeaderView from "../ItemHeaderView"; export default function ItemView({ activeItem }){ + const [activeTab, setActiveTab] = useState('Conversation'); const theme = useTheme(); const useStyles = makeStyles({ @@ -12,15 +17,35 @@ export default function ItemView({ activeItem }){ paddingTop: theme.spacing(1), paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), - border: "none" + border: "none", + }, + "tabPanelPadding": { + padding: `${theme.spacing(2)}px ${theme.spacing(2)}px` } }); const classes = useStyles(); -return( + const handleTabChange = (event, newValue) => { + setActiveTab(newValue); + }; + + return( - + + + + + + + + + + + + + + ); }; diff --git a/src/components/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/LoginForm/LoginForm.js b/src/components/LoginForm/LoginForm.js new file mode 100644 index 0000000..5eb2392 --- /dev/null +++ b/src/components/LoginForm/LoginForm.js @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { Box, Paper, TextField, Button, Avatar, Typography, InputAdornment, IconButton, useTheme, makeStyles } from "@material-ui/core"; +import Visibility from '@material-ui/icons/Visibility'; +import VisibilityOff from '@material-ui/icons/VisibilityOff'; +import { Redirect } from "react-router-dom"; +import { Alert } from '@material-ui/lab'; +import { useLogin, useLoginSetter, useTokenSetter } from "../AuthProvider/"; +import { login } from "../../auth/"; + +export default function LoginForm() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const handleUsernameChange = (event) => setUsername(event.target.value); + const handlePasswordChange = (event) => setPassword(event.target.value); + + const setLogin = useLoginSetter(); + const setToken = useTokenSetter(); + const handleSubmit = async (event) => { + event.preventDefault(); + let access_token = await login(username, password); + + if (!access_token){ + setError(true); + return false; + } + + setLogin(true); + setToken(access_token); + return true; + } + + const theme = useTheme(); + const useStyles = makeStyles({ + "box_root": { + background: `linear-gradient(120deg, ${theme.palette.secondary.main}35 0%, ${theme.palette.primary.main}15 100%)`, + width: "100%", + height: "100vh", + display: "flex", + justifyContent: "center", + alignItems: "center" + }, + "avatar_root": { + padding: theme.spacing(1), + width: theme.spacing(10), + height: theme.spacing(10) + }, + "alert_root": { + marginTop: theme.spacing(2) + }, + "paper_root": { + minWidth: theme.breakpoints.values.sm/2, + padding: theme.spacing(3), + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + }, + "button_root": { + marginTop: theme.spacing(2) + } + }) + const classes = useStyles(); + + const isLoggedIn = useLogin(); + if (isLoggedIn) { + return + } + + const LoginErrorAlert = _ => { + return ( + + Username or password is incorrect. + + ); + } + + const ViewPasswordToggle = _ => { + return ( + + setShowPassword(!showPassword) } + onMouseDown={ (event) => event.preventDefault() } + > + { showPassword ? : } + + + ); + } + + return ( + +
+ + + + Sign In + + { error && } + + + }} + /> + + + +
+ ); +}; \ No newline at end of file diff --git a/src/components/LoginForm/LoginForm.md b/src/components/LoginForm/LoginForm.md new file mode 100644 index 0000000..7e0553c --- /dev/null +++ b/src/components/LoginForm/LoginForm.md @@ -0,0 +1,11 @@ +The LoginForm acts as the only public facing page for the webqueue2. If any part of the app is access without access tokens, the user will be redirected here. It takes a username and password, attempts to login an, if successful, sets access tokens and redirects users to webqueue2. + +--- +```jsx +import LoginForm from "./LoginForm"; + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/LoginForm/index.js b/src/components/LoginForm/index.js new file mode 100644 index 0000000..3789b92 --- /dev/null +++ b/src/components/LoginForm/index.js @@ -0,0 +1 @@ +export { default } from "./LoginForm"; \ No newline at end of file diff --git a/src/components/PrivateRoute/PrivateRoute.js b/src/components/PrivateRoute/PrivateRoute.js new file mode 100644 index 0000000..9a7ebad --- /dev/null +++ b/src/components/PrivateRoute/PrivateRoute.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from "prop-types"; +import { Route, Redirect } from 'react-router-dom'; +import { useLogin } from "../AuthProvider/"; + +export default function PrivateRoute({ children, ...rest }) { + const isLoggedIn = useLogin(); + + return ( + + { + isLoggedIn + ? children + : + } + + ); +}; + +PrivateRoute.propTypes = { + /** The route's path. */ + "path": PropTypes.string.isRequired +}; \ No newline at end of file diff --git a/src/components/PrivateRoute/PrivateRoute.md b/src/components/PrivateRoute/PrivateRoute.md new file mode 100644 index 0000000..7f0d5c0 --- /dev/null +++ b/src/components/PrivateRoute/PrivateRoute.md @@ -0,0 +1 @@ +The PrivateRoute wraps [React Router](https://reactrouter.com/)'s [Route component](https://reactrouter.com/web/api/Route) and checks for authentication using [AuthProvider](#/Components/AuthProvider). If authentication is valid, the children of the PrivateRoute are rendered. Otherwise, the user is redirected to the login page. \ No newline at end of file diff --git a/src/components/PrivateRoute/index.js b/src/components/PrivateRoute/index.js new file mode 100644 index 0000000..4c9765d --- /dev/null +++ b/src/components/PrivateRoute/index.js @@ -0,0 +1 @@ +export { default } from "./PrivateRoute"; \ No newline at end of file diff --git a/src/components/QueueSelector/QueueSelector.js b/src/components/QueueSelector/QueueSelector.js new file mode 100644 index 0000000..aa1a816 --- /dev/null +++ b/src/components/QueueSelector/QueueSelector.js @@ -0,0 +1,176 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { TextField, Checkbox, InputAdornment, Box, useTheme } from "@material-ui/core"; +import { Autocomplete } from "@material-ui/lab"; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import { useCookies } from "react-cookie"; +import { useToken } from "../AuthProvider/"; + +/** + * Get queue names and number of items. + * @param {String} access_token A valid API access token. + * @returns Array of objects containing queue names and item counts. + */ +const getQueueCounts = async (access_token) => { + if (access_token === null){ + return undefined + } + + let myHeaders = new Headers(); + myHeaders.append("Authorization", `Bearer ${access_token}`); + let requestOptions = { headers: myHeaders }; + + const apiResponse = await fetch(`/api/get_queues`, requestOptions); + const queueCountJson = await apiResponse.json(); + + return queueCountJson; +}; + +export default function QueueSelector({ open, setOpen, value, setValue }) { + const [queueCounts, setQueueCounts] = useState([]); + const [isFirstRender, setIsFirstRender] = useState(true); + const access_token = useToken(); + const loading = open && queueCounts.length === 0; + + const [cookies, setCookie] = useCookies(["active-queues"]); + const activeQueues = cookies['active-queues'] !== undefined ? cookies['active-queues'].split(',') : []; + + const theme = useTheme(); + + // Prepopulate Active Queues from Cookies + useEffect( _ => { + if (access_token === null){ + return undefined; + } + + if (isFirstRender) { + ( async _ => { + // Get queue counts + let queueCountsJson = await getQueueCounts(access_token); + + // Find queue count info for queue names in active queues + let activeQueuesInfo = activeQueues.map((queueName) => ( + queueCountsJson.find( ({ name }) => queueName === name ) + )); + + // Filter undefined values + activeQueuesInfo = activeQueuesInfo.filter( (entry) => entry !== undefined); + + setValue(activeQueuesInfo); + setIsFirstRender(false); + })(); + } + }, []); + + // Get queue counts if QueueSelector is open + useEffect( _ => { + (async _ => { + if (loading) { + let queueCountsJson = await getQueueCounts(access_token); + setQueueCounts(queueCountsJson); + } + })() + }, [loading, access_token]); + + // Delete queue counts if QueueSelector is closed + useEffect(() => { + if (!open) { + setQueueCounts([]); + } + }, [open]); + + const handleChange = (event, newValue) => { + setValue(newValue) + + // Set active-queues cookie to csv of selected queue names + const activeQueueOptions = { + path: "/", + expires: (_ => { + let expiration_date = new Date(); + expiration_date.setDate(expiration_date.getDate() + 365); + return expiration_date; + })() + }; + const activeQueues = newValue.map( (value) => value.name).join(','); + setCookie("active-queues", activeQueues, activeQueueOptions); + }; + + // Function to render checkboxes in dropdown + // See `renderOptions` prop at https://material-ui.com/api/autocomplete/#props + const optionRenderer = (option, { selected }) => ( + <> + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + {`${option.name} (${option.number_of_items})`} + + ); + + return( + // Box is used for margin because Autocomplete CSS overrides don't work as expected. + + ( + + + Active Queues: + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + <> + {loading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + )} + options={queueCounts} + value={value} + onChange={handleChange} + getOptionLabel={(option) => `${option.name} (${option.number_of_items})`} + renderOption={optionRenderer} + getOptionSelected={ (option, value) => option.name === value.name } + size="small" + open={open} + onOpen={_ => setOpen(true)} + onClose={_ => setOpen(false)} + loading={true} + disableCloseOnSelect + disableListWrap + fullWidth + multiple + autoHighlight + /> + + ); +}; + +QueueSelector.propTypes = { + /** State variable to manage open status. */ + "open": PropTypes.bool.isRequired, + /** Function to update state variable that manages open status. */ + "setOpen": PropTypes.func.isRequired, + /** State variable to manage selected queues. */ + "value": PropTypes.array.isRequired, + /** Function to update state variable that manages selected queues. */ + "setValue": PropTypes.func.isRequired, +}; \ No newline at end of file diff --git a/src/components/QueueSelector/QueueSelector.md b/src/components/QueueSelector/QueueSelector.md new file mode 100644 index 0000000..ea505eb --- /dev/null +++ b/src/components/QueueSelector/QueueSelector.md @@ -0,0 +1,38 @@ +Allows the selection, removal and viewing of active queues. Its extends the [MUI Autocomplete component](https://material-ui.com/components/autocomplete/). + +--- +```jsx +import React, { useState } from "react"; +import { Paper, makeStyles } from "@material-ui/core"; +import QueueSelector from "./QueueSelector"; + +const [selectedQueues, setSelectedQueues] = useState([]); +const queues = [ + { + 'name': 'bidc', + 'number_of_items': 5 + }, + { + 'name': 'epics', + 'number_of_items': 6 + }, + { + 'name': 'wang', + 'number_of_items': 13 + } +]; + +const useStyles = makeStyles({ + root: { + padding: "16px", + } +}); +const classes = useStyles(); + + + + +``` +```jsx static + +``` \ No newline at end of file diff --git a/src/components/QueueSelector/index.js b/src/components/QueueSelector/index.js new file mode 100644 index 0000000..4686319 --- /dev/null +++ b/src/components/QueueSelector/index.js @@ -0,0 +1 @@ +export { default } from "./QueueSelector"; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 578b96b..799ad12 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,8 @@ import * as serviceWorker from './serviceWorker'; import { createBrowserHistory } from 'history'; import { CssBaseline } from '@material-ui/core'; import { BrowserRouter as Router } from 'react-router-dom'; +import { CookiesProvider } from "react-cookie"; +import AuthProvider from "./components/AuthProvider/"; export const history = createBrowserHistory({ basename: process.env.PUBLIC_URL @@ -13,9 +15,13 @@ export const history = createBrowserHistory({ ReactDOM.render( - - - + + + + + + + , document.getElementById('root') ); 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}')