diff --git a/.gitignore b/.gitignore index acc39b8..c518367 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ yarn-error.log* # Python Files /api/venv -__pycache__/ \ No newline at end of file +__pycache__/ +venv-manager.log diff --git a/api/ECNQueue.py b/api/ECNQueue.py index d0c8ea9..82b0e7e 100644 --- a/api/ECNQueue.py +++ b/api/ECNQueue.py @@ -54,7 +54,7 @@ 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.__getMostRecentHeaderByType("Date") + self.dateReceived = self.__getParsedDate(self.__getMostRecentHeaderByType("Date")) self.jsonData = { "queue": self.queue, @@ -608,6 +608,23 @@ def __getAssignedTo(self) -> str: """ assignedTo = self.__getMostRecentHeaderByType("Assigned-To") return assignedTo + + def __getFormattedDate(self, date: str) -> str: + """Returns the date/time formatted as RFC 8601 YYYY-MM-DDTHH:MM:SS+00:00. + Returns empty string if the string argument passed to the function is not a datetime. + See: https://en.wikipedia.org/wiki/ISO_8601 + + Returns: + str: Properly formatted date/time recieved or empty string. + """ + try: + parsedDate = parse(date) + except: + return "" + + parsedDateString = parsedDate.strftime("%Y-%m-%dT%H:%M:%S%z") + + return parsedDateString def toJson(self) -> dict: """Returns a JSON safe representation of the item. diff --git a/package.json b/package.json index b785b26..db88807 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "start:docs": "npx styleguidist server --open --config styleguidist/styleguide.config.js", "build:frontend": "react-scripts build", "build:docs": "npx styleguidist build --config styleguidist/styleguide.config.js", + "venv:create": "python3 utils/venv-manager.py create", + "venv:delete": "python3 utils/venv-manager.py delete", + "venv:reset": "python3 utils/venv-manager.py reset", "test": "react-scripts test", "eject": "react-scripts eject" }, diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..60a7d4a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +aniso8601==8.0.0 +astroid==2.4.2 +attrs==20.1.0 +click==7.1.2 +Flask==1.1.2 +Flask-RESTful==0.3.8 +gunicorn==20.0.4 +importlib-metadata==1.7.0 +iniconfig==1.0.1 +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 +more-itertools==8.5.0 +packaging==20.4 +pkg-resources==0.0.0 +pluggy==0.13.1 +py==1.9.0 +pylint==2.5.3 +pyparsing==2.4.7 +pytest==6.0.1 +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 +zipp==3.1.0 diff --git a/testfile b/testfile new file mode 100644 index 0000000..2691857 --- /dev/null +++ b/testfile @@ -0,0 +1 @@ +testfile diff --git a/utils/venv-manager.py b/utils/venv-manager.py new file mode 100644 index 0000000..d19da5c --- /dev/null +++ b/utils/venv-manager.py @@ -0,0 +1,247 @@ +"""Allows for creating, deleting and removing Python virtual environments in webqueue2 + +Examples: + Create a virtual environment: + $ venv-manager.py create + + Delete a virtual environment: + $ venv-manager.py delete + + Reset a virtual environment: + $ venv-manager.py reset +""" + +from pathlib import Path +import os, logging, argparse, subprocess +from typing import Union + + +################################################################################ +# Configuration +################################################################################ + +# Set virtual environment path +WEBQUEUE2_DIR = Path(os.path.abspath(__file__)).parent.parent +API_DIR = Path(WEBQUEUE2_DIR, "api") +VENV_NAME = "venv" +VENV_DIR = Path(API_DIR, VENV_NAME) + + +# Set minimum pip major version +TARGET_PIP_VERSION = 19 + + +# Configure the logger +logger_name = "venv-manager" +logger = logging.getLogger(logger_name) +logger.setLevel(logging.INFO) + +# See: https://docs.python.org/3/library/logging.html#logrecord-attributes +log_message_format = "%(asctime)s %(name)s : [%(levelname)s] %(message)s" +# See: https://docs.python.org/3.6/library/time.html#time.strftime +log_time_format = "%b %d %Y %H:%M:%S" +log_formatter = logging.Formatter(log_message_format, log_time_format) + +stream_handler = logging.StreamHandler() +stream_handler.setFormatter(log_formatter) + +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) + + +################################################################################ +# Functions +################################################################################ + + +def get_args() -> argparse.Namespace: + """Parses arguments and returns argparses's generated namespace. + + Returns: + argparse.Namespace: Argparses's generated namespace. + """ + parser = argparse.ArgumentParser() + parser.add_argument( + "action", + help="Action to perform.", + choices=("create", "delete", "reset") + ) + parser.add_argument( + "--debug", + help="Print debug logs", + action="store_true", + ) + return parser.parse_args() + + +def run_logged_subprocess(command: Union[str, list], timeout: int = 10, shell: bool = True) -> tuple: + """Executes a shell command using subprocess with logging. + + stderr is redirected to stdout and stdout is pipped to logger. + If the subprocess raises an exception, the exception is logged as critical. + + Example: + Running a successful command: + run_logged_subprocess(command=["git", "commit", "-m", "'Commit message.'"]) + Returns: (0, "") + + Running an unsuccessful shell command with a 20 second timeout: + run_logged_subprocess(command="cd test/", timeout=20, shell=True) + Returns: (1, "cd: test: No such file or directory\n") + + 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 + + Returns: + tuple: With the first value being the return code and second being the combined stdout+stderr + """ + logger.debug(f"Entering subprocess for '{command}'") + with subprocess.Popen(command,\ + stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=shell, text=True)\ + as logged_shell_process: + + subprocess_log_prefix = f"(PID: {logged_shell_process.pid})" + + try: + # Convert combined stdout and stderr stream to list of strings + process_output_stream, _ = logged_shell_process.communicate(timeout=timeout) + process_output_lines = process_output_stream.split("\n") + # Remove last entry in process_output_lines because it is always empty + process_output_lines.pop(-1) + + for line in process_output_lines: + logger.debug(f"{subprocess_log_prefix}: {line}") + except Exception as exception: + logger.critical(str(exception)) + else: + if logged_shell_process.returncode != 0: + logger.debug(f"Something went wrong. '{command}' exited with return code {logged_shell_process.returncode}") + elif logged_shell_process.returncode == 0: + logger.debug(f"Subprocess for '{command}' completed successfuly") + finally: + logger.debug(f"Exiting subprocess for '{command}'") + return (logged_shell_process.returncode, process_output_stream) + + +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 + """ + + # Check for an existing virtual environment + try: + os.mkdir(VENV_DIR) + except FileExistsError: + logger.warning(f"The directory {VENV_DIR} already exists. Exiting") + return 5 + + # Create virtual environment + logger.info(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}") + else: + logger.critical(f"Could not create virtual environment {VENV_NAME} at {VENV_DIR}. Exiting") + return 10 + + # 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}") + + 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") + 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]) + else: + logger.warning("Failed to update pip. Virtual environment dependencies may not install") + + # Install requirements + logger.info("Installing requirements") + install_requirements_returncode, _ = run_logged_subprocess(f"{VENV_DIR}/bin/pip install -r {API_DIR}/requirements.txt") + if install_requirements_returncode == 0: + logger.info("Successfully installed requirements") + return 0 + else: + logger.critical("Failed to install requirements. Exiting") + return 15 + + +def delete_environment() -> int: + """Deletes a virtual environment for webqueue2 + + Exit Codes: + 0 = Success + 5 = Could not delete VENV_DIR + + Returns: + int: Exit code + """ + delete_venv_returncode, _ = run_logged_subprocess(f"rm -rf {VENV_DIR}") + if delete_venv_returncode == 0: + logger.info(f"Successfully deleted virtual environment {VENV_DIR} at {VENV_DIR}") + return 0 + else: + logger.critical(f"Failed to delete virtual environment {VENV_DIR} at {VENV_DIR}. Exiting") + return 5 + +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 + """ + delete_returncode = delete_environment() + if delete_returncode != 0: + logger.critical(f"Failed to reset virtual environment {VENV_DIR} at {VENV_DIR}. Exiting") + return 5 + + create_returncode = create_environment() + if create_returncode != 0: + logger.critical(f"Failed to reset virtual environment {VENV_DIR} at {VENV_DIR}. Exiting") + return 10 + + logger.info(f"Successfully reset virtual environment {VENV_DIR} at {VENV_DIR}. Exiting") + + +if __name__ == "__main__": + args = get_args() + action = args.action + + if args.debug: + logger.setLevel(logging.DEBUG) + + if action == "create": + exit(create_environment()) + elif action == "delete": + exit(delete_environment()) + elif action == "reset": + exit(reset_environment()) + else: + logger.critical(f'Invalid argument {action}') \ No newline at end of file