From 54b3e480a54067e071ac321a46ecf4c672700c7f Mon Sep 17 00:00:00 2001 From: Daniel Markstedt Date: Sat, 6 Nov 2021 17:25:02 -0700 Subject: [PATCH] Web UI code cleanup and refactoring (#409) * Remove dead code * Clean up indentation * Cleanup * Move socket commands into its own file * Move non-rascsi command methods into its own file * Refactoring * Bring back list_config_files * Cleanup * Cleanup of status messages * Remove unused libraries * Resolve pylint warnings * Resolve pylint warnings * Remove unused library * Resolve pylint warnings * Clean up status messages * Add requests lib to requirements.txt * Clean up status messages * Resolve interpolation warnings for logging * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Cleanup * Add html/head/body tags to the base document * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Resolve pylint warnings * Add .pylintrc and suppress warnings for the generated protobuf module * Resolve pylint warnings * Clean up docstrings * Fix error * Cleanup * Add info on pylint to README * Store .pylintrc in parent dir to allow other Python packages to use it * Tidy index.html * Cleanup * Resolve jinja-ninja warnings * Cleanup * Cleanup * Cleanup * Cleanup * Cleanup * Fix wording --- src/.pylintrc | 549 +++++++++++++++++++++++ src/web/.pylintrc | 1 + src/web/README.md | 13 +- src/web/create_disk.py | 45 -- src/web/device_utils.py | 49 +++ src/web/file_cmds.py | 216 ++++----- src/web/pi_cmds.py | 29 +- src/web/ractl_cmds.py | 355 +++++---------- src/web/requirements.txt | 8 +- src/web/settings.py | 28 +- src/web/socket_cmds.py | 92 ++++ src/web/start.sh | 34 +- src/web/templates/base.html | 15 +- src/web/templates/drives.html | 266 ++++++------ src/web/templates/index.html | 793 ++++++++++++++++++---------------- src/web/web.py | 591 ++++++++++++++----------- 16 files changed, 1885 insertions(+), 1199 deletions(-) create mode 100644 src/.pylintrc create mode 120000 src/web/.pylintrc delete mode 100644 src/web/create_disk.py create mode 100644 src/web/device_utils.py create mode 100644 src/web/socket_cmds.py diff --git a/src/.pylintrc b/src/.pylintrc new file mode 100644 index 00000000..ad08d2d1 --- /dev/null +++ b/src/.pylintrc @@ -0,0 +1,549 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + invalid-unicode-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + locally-enabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=optparse.Values,sys.exit + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +#method-rgx= + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules=rascsi_interface_pb2 + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,io,builtins + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/src/web/.pylintrc b/src/web/.pylintrc new file mode 120000 index 00000000..30b33b52 --- /dev/null +++ b/src/web/.pylintrc @@ -0,0 +1 @@ +../.pylintrc \ No newline at end of file diff --git a/src/web/README.md b/src/web/README.md index 8ad84a76..8a7a79f3 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -17,7 +17,18 @@ $ BASE_DIR=/tmp/images/ PATH=$PATH:`pwd`/mock/bin/ python3 web.py You may edit the files under `mock/bin` to simulate Linux command responses. TODO: rascsi-web uses protobuf commands to send and receive data from rascsi. -A separate mocking solusion will be needed for this interface. +A separate mocking solution will be needed for this interface. + +### Static analysis with pylint + +It is recommended to run pylint against new code to protect against bugs +and keep the code readable and maintainable. +The local pylint configuration lives in .pylintrc + +``` +sudo apt install pylint3 +pylint3 python_source_file.py +``` ## Pushing to the Pi via git diff --git a/src/web/create_disk.py b/src/web/create_disk.py deleted file mode 100644 index 6bc521ff..00000000 --- a/src/web/create_disk.py +++ /dev/null @@ -1,45 +0,0 @@ -from machfs import Volume, Folder, File - -from settings import * - - -# Build a cd and attempt to fix resource forks if known -def make_cd(file_path, file_type, file_creator): - with open(file_path, "rb") as f: - file_bytes = f.read() - file_name = file_path.split("/")[-1] - file_suffix = file_name.split(".")[-1] - - if file_type is None and file_creator is None: - if file_suffix.lower() == "sea": - file_type = ".sea" - file_creator = "APPL" - - v = Volume() - v.name = "TestName" - - v["Folder"] = Folder() - - v["Folder"][file_name] = File() - v["Folder"][file_name].data = file_bytes - v["Folder"][file_name].rsrc = b"" - if not (file_type is None and file_creator is None): - v["Folder"][file_name].type = bytearray(file_type) - v["Folder"][file_name].creator = bytearray(file_creator) - - padding = (len(file_bytes) % 512) + (512 * 50) - print("mod", str(len(file_bytes) % 512)) - print("padding " + str(padding)) - print("len " + str(len(file_bytes))) - print("total " + str(len(file_bytes) + padding)) - with open(base_dir + "test.hda", "wb") as f: - flat = v.write( - size=len(file_bytes) + padding, - align=512, # Allocation block alignment modulus (2048 for CDs) - desktopdb=True, # Create a dummy Desktop Database to prevent a rebuild on boot - bootable=False, # This requires a folder with a ZSYS and a FNDR file - startapp=("Folder", file_name), # Path (as tuple) to an app to open at boot - ) - f.write(flat) - - return base_dir + "test.hda" diff --git a/src/web/device_utils.py b/src/web/device_utils.py new file mode 100644 index 00000000..cd644c8c --- /dev/null +++ b/src/web/device_utils.py @@ -0,0 +1,49 @@ +""" +Module for RaSCSI device management utility methods +""" + +def get_valid_scsi_ids(devices, reserved_ids): + """ + Takes a list of (dict)s devices, and list of (int)s reserved_ids. + Returns: + - (list) of (int)s valid_ids, which are the SCSI ids that are not reserved + - (int) recommended_id, which is the id that the Web UI should default to recommend + """ + occupied_ids = [] + for device in devices: + occupied_ids.append(device["id"]) + + unoccupied_ids = [i for i in list(range(8)) if i not in reserved_ids + occupied_ids] + unoccupied_ids.sort() + valid_ids = [i for i in list(range(8)) if i not in reserved_ids] + valid_ids.sort(reverse=True) + + if unoccupied_ids: + recommended_id = unoccupied_ids[-1] + else: + recommended_id = occupied_ids.pop(0) + + return valid_ids, recommended_id + + +def sort_and_format_devices(devices): + """ + Takes a (list) of (dict)s devices and returns a (list) of (dict)s. + Sorts by SCSI ID acending (0 to 7). + For SCSI IDs where no device is attached, inject a (dict) with placeholder text. + """ + occupied_ids = [] + for device in devices: + occupied_ids.append(device["id"]) + + formatted_devices = devices + + # Add padding devices and sort the list + for i in range(8): + if i not in occupied_ids: + formatted_devices.append({"id": i, "device_type": "-", \ + "status": "-", "file": "-", "product": "-"}) + # Sort list of devices by id + formatted_devices.sort(key=lambda dic: str(dic["id"])) + + return formatted_devices diff --git a/src/web/file_cmds.py b/src/web/file_cmds.py index 516c0064..2d259d7a 100644 --- a/src/web/file_cmds.py +++ b/src/web/file_cmds.py @@ -1,5 +1,10 @@ +""" +Module for methods reading from and writing to the file system +""" + import os import logging +from pathlib import PurePath from ractl_cmds import ( get_server_info, @@ -8,15 +13,15 @@ from ractl_cmds import ( list_devices, send_pb_command, ) -from settings import * +from settings import CFG_DIR, CONFIG_FILE_SUFFIX, PROPERTIES_SUFFIX import rascsi_interface_pb2 as proto def list_files(file_types, dir_path): """ - Takes a list or tuple of str file_types - e.g. ('hda', 'hds') - Returns list of lists files_list: - index 0 is str file name and index 1 is int size in bytes + Takes a (list) or (tuple) of (str) file_types - e.g. ('hda', 'hds') + Returns (list) of (list)s files_list: + index 0 is (str) file name and index 1 is (int) size in bytes """ files_list = [] for path, dirs, files in os.walk(dir_path): @@ -36,13 +41,13 @@ def list_files(file_types, dir_path): def list_config_files(): """ - Returns a list of RaSCSI config files in cfg_dir: - list of str files_list + Finds fils with file ending CONFIG_FILE_SUFFIX in CFG_DIR. + Returns a (list) of (str) files_list """ files_list = [] - for root, dirs, files in os.walk(cfg_dir): + for root, dirs, files in os.walk(CFG_DIR): for file in files: - if file.endswith(".json"): + if file.endswith("." + CONFIG_FILE_SUFFIX): files_list.append(file) return files_list @@ -50,7 +55,7 @@ def list_config_files(): def list_images(): """ Sends a IMAGE_FILES_INFO command to the server - Returns a dict with boolean status, str msg, and list of dicts files + Returns a (dict) with (bool) status, (str) msg, and (list) of (dict)s files """ command = proto.PbCommand() @@ -60,56 +65,53 @@ def list_images(): result = proto.PbResult() result.ParseFromString(data) - # Get a list of all *.properties files in cfg_dir - from pathlib import PurePath - prop_data = list_files(PROPERTIES_SUFFIX, cfg_dir) + # Get a list of all *.properties files in CFG_DIR + prop_data = list_files(PROPERTIES_SUFFIX, CFG_DIR) prop_files = [PurePath(x[0]).stem for x in prop_data] from zipfile import ZipFile, is_zipfile server_info = get_server_info() files = [] - for f in result.image_files_info.image_files: + for file in result.image_files_info.image_files: # Add properties meta data for the image, if applicable - if f.name in prop_files: - process = read_drive_properties(f"{cfg_dir}/{f.name}.{PROPERTIES_SUFFIX}") + if file.name in prop_files: + process = read_drive_properties(f"{CFG_DIR}/{file.name}.{PROPERTIES_SUFFIX}") prop = process["conf"] else: prop = False - if f.name.lower().endswith(".zip"): - zip_path = f"{server_info['image_dir']}/{f.name}" + if file.name.lower().endswith(".zip"): + zip_path = f"{server_info['image_dir']}/{file.name}" if is_zipfile(zip_path): - zip = ZipFile(zip_path) - # Get a list of str containing all zipfile members - zip_members = zip.namelist() + zipfile = ZipFile(zip_path) + # Get a list of (str) containing all zipfile members + zip_members = zipfile.namelist() # Strip out directories from the list zip_members = [x for x in zip_members if not x.endswith("/")] else: - logging.warning(f"{zip_path} is an invalid zip file") + logging.warning("%s is an invalid zip file", zip_path) zip_members = False else: zip_members = False - size_mb = "{:,.1f}".format(f.size / 1024 / 1024) - dtype = proto.PbDeviceType.Name(f.type) - files.append( - { - "name": f.name, - "size": f.size, - "size_mb": size_mb, - "detected_type": dtype, - "prop": prop, - "zip": zip_members, - } - ) + size_mb = "{:,.1f}".format(file.size / 1024 / 1024) + dtype = proto.PbDeviceType.Name(file.type) + files.append({ + "name": file.name, + "size": file.size, + "size_mb": size_mb, + "detected_type": dtype, + "prop": prop, + "zip": zip_members, + }) return {"status": result.status, "msg": result.msg, "files": files} def create_new_image(file_name, file_type, size): """ - Takes str file_name, str file_type, and int size + Takes (str) file_name, (str) file_type, and (int) size Sends a CREATE_IMAGE command to the server - Returns dict with boolean status and str msg + Returns (dict) with (bool) status and (str) msg """ command = proto.PbCommand() command.operation = proto.PbOperation.CREATE_IMAGE @@ -126,9 +128,9 @@ def create_new_image(file_name, file_type, size): def delete_image(file_name): """ - Takes str file_name + Takes (str) file_name Sends a DELETE_IMAGE command to the server - Returns dict with boolean status and str msg + Returns (dict) with (bool) status and (str) msg """ command = proto.PbCommand() command.operation = proto.PbOperation.DELETE_IMAGE @@ -143,26 +145,25 @@ def delete_image(file_name): def delete_file(file_path): """ - Takes str file_path with the full path to the file to delete - Returns dict with boolean status and str msg + Takes (str) file_path with the full path to the file to delete + Returns (dict) with (bool) status and (str) msg """ if os.path.exists(file_path): os.remove(file_path) - return {"status": True, "msg": "File deleted"} - else: - return {"status": False, "msg": "Could not delete file"} + return {"status": True, "msg": f"File deleted: {file_path}"} + return {"status": False, "msg": f"File to delete not found: {file_path}"} def unzip_file(file_name, member=False): """ Takes (str) file_name, optional (str) member - Returns dict with (boolean) status and (list of str) msg + Returns (dict) with (boolean) status and (list of str) msg """ from subprocess import run from re import escape server_info = get_server_info() - if member == False: + if not member: unzip_proc = run( ["unzip", "-d", server_info["image_dir"], "-n", "-j", \ f"{server_info['image_dir']}/{file_name}"], capture_output=True @@ -173,26 +174,29 @@ def unzip_file(file_name, member=False): f"{server_info['image_dir']}/{file_name}", escape(member)], capture_output=True ) if unzip_proc.returncode != 0: - stderr = unzip_proc.stderr.decode("utf-8") - logging.warning(f"Unzipping failed: {stderr}") + stderr = unzip_proc.stderr.decode("utf-8") + logging.warning("Unzipping failed: %s", stderr) return {"status": False, "msg": stderr} from re import findall - unzipped = findall("(?:inflating|extracting):(.+)\n", unzip_proc.stdout.decode("utf-8")) + unzipped = findall( + "(?:inflating|extracting):(.+)\n", + unzip_proc.stdout.decode("utf-8") + ) return {"status": True, "msg": unzipped} -def download_file_to_iso(scsi_id, url): +def download_file_to_iso(url): """ - Takes int scsi_id and str url - Returns dict with boolean status and str msg + Takes (int) scsi_id and (str) url + Returns (dict) with (bool) status and (str) msg """ from time import time from subprocess import run server_info = get_server_info() - file_name = url.split("/")[-1] + file_name = PurePath(url).name tmp_ts = int(time()) tmp_dir = "/tmp/" + str(tmp_ts) + "/" os.mkdir(tmp_dir) @@ -201,7 +205,7 @@ def download_file_to_iso(scsi_id, url): req_proc = download_to_dir(url, tmp_dir) - if req_proc["status"] == False: + if not req_proc["status"]: return {"status": False, "msg": req_proc["msg"]} iso_proc = run( @@ -215,13 +219,12 @@ def download_file_to_iso(scsi_id, url): def download_to_dir(url, save_dir): """ - Takes str url, str save_dir - Returns dict with boolean status and str msg + Takes (str) url, (str) save_dir + Returns (dict) with (bool) status and (str) msg """ import requests - from pathlib import PurePath file_name = PurePath(url).name - logging.info(f"Making a request to download {url}") + logging.info("Making a request to download %s", url) try: with requests.get(url, stream=True, headers={"User-Agent": "Mozilla/5.0"}) as req: @@ -229,29 +232,28 @@ def download_to_dir(url, save_dir): with open(f"{save_dir}/{file_name}", "wb") as download: for chunk in req.iter_content(chunk_size=8192): download.write(chunk) - except requests.exceptions.RequestException as e: - logging.warning(f"Request failed: {str(e)}") - return {"status": False, "msg": str(e)} + except requests.exceptions.RequestException as error: + logging.warning("Request failed: %s", str(error)) + return {"status": False, "msg": str(error)} + logging.info("Response encoding: %s", req.encoding) + logging.info("Response content-type: %s", req.headers["content-type"]) + logging.info("Response status code: %s", req.status_code) - logging.info(f"Response encoding: {req.encoding}") - logging.info(f"Response content-type: {req.headers['content-type']}") - logging.info(f"Response status code: {req.status_code}") - - return {"status": True, "msg": f"{url} downloaded to {save_dir}"} + return {"status": True, "msg": f"File downloaded from {url} to {save_dir}"} def write_config(file_name): """ - Takes str file_name - Returns dict with boolean status and str msg + Takes (str) file_name + Returns (dict) with (bool) status and (str) msg """ from json import dump - file_name = cfg_dir + file_name + file_name = CFG_DIR + file_name try: with open(file_name, "w") as json_file: devices = list_devices()["device_list"] - if len(devices) == 0: + if not devices: return {"status": False, "msg": "No attached devices."} for device in devices: # Remove keys that we don't want to store in the file @@ -269,86 +271,90 @@ def write_config(file_name): # Convert to a data type that can be serialized device["params"] = dict(device["params"]) dump(devices, json_file, indent=4) - return {"status": True, "msg": f"Successfully wrote to file: {file_name}"} - except (IOError, ValueError, EOFError, TypeError) as e: - logging.error(str(e)) + return {"status": True, "msg": f"Saved config to {file_name}"} + except (IOError, ValueError, EOFError, TypeError) as error: + logging.error(str(error)) delete_file(file_name) - return {"status": False, "msg": str(e)} + return {"status": False, "msg": str(error)} except: - logging.error(f"Could not write to file: {file_name}") + logging.error("Could not write to file: %s", file_name) delete_file(file_name) return {"status": False, "msg": f"Could not write to file: {file_name}"} def read_config(file_name): """ - Takes str file_name - Returns dict with boolean status and str msg + Takes (str) file_name + Returns (dict) with (bool) status and (str) msg """ from json import load - file_name = cfg_dir + file_name + file_name = CFG_DIR + file_name try: with open(file_name) as json_file: detach_all() devices = load(json_file) for row in devices: - kwargs = {"device_type": row["device_type"], \ - "image": row["image"], "unit": int(row["un"]), \ - "vendor": row["vendor"], "product": row["product"], \ - "revision": row["revision"], "block_size": row["block_size"]} + kwargs = { + "device_type": row["device_type"], + "image": row["image"], + "unit": int(row["un"]), + "vendor": row["vendor"], + "product": row["product"], + "revision": row["revision"], + "block_size": row["block_size"], + } params = dict(row["params"]) - for p in params.keys(): - kwargs[p] = params[p] + for param in params.keys(): + kwargs[param] = params[param] process = attach_image(row["id"], **kwargs) - if process["status"] == True: - return {"status": process["status"], "msg": f"Successfully read from file: {file_name}"} - else: - return {"status": process["status"], "msg": process["msg"]} - except (IOError, ValueError, EOFError, TypeError) as e: - logging.error(str(e)) - return {"status": False, "msg": str(e)} + if process["status"]: + return {"status": process["status"], "msg": f"Loaded config from: {file_name}"} + return {"status": process["status"], "msg": process["msg"]} + except (IOError, ValueError, EOFError, TypeError) as error: + logging.error(str(error)) + return {"status": False, "msg": str(error)} except: - logging.error(f"Could not read file: {file_name}") + logging.error("Could not read file: %s", file_name) return {"status": False, "msg": f"Could not read file: {file_name}"} def write_drive_properties(file_name, conf): """ Writes a drive property configuration file to the config dir. - Takes file name base (str) and conf (list of dicts) as arguments - Returns dict with boolean status and str msg + Takes file name base (str) and (list of dicts) conf as arguments + Returns (dict) with (bool) status and (str) msg """ from json import dump - file_path = cfg_dir + file_name + file_path = CFG_DIR + file_name try: with open(file_path, "w") as json_file: dump(conf, json_file, indent=4) - return {"status": True, "msg": f"Successfully wrote to file: {file_path}"} - except (IOError, ValueError, EOFError, TypeError) as e: - logging.error(str(e)) + return {"status": True, "msg": f"Created file: {file_path}"} + except (IOError, ValueError, EOFError, TypeError) as error: + logging.error(str(error)) delete_file(file_path) - return {"status": False, "msg": str(e)} + return {"status": False, "msg": str(error)} except: - logging.error(f"Could not write to file: {file_path}") + logging.error("Could not write to file: %s", file_path) delete_file(file_path) return {"status": False, "msg": f"Could not write to file: {file_path}"} def read_drive_properties(path_name): - """ + """ Reads drive properties from json formatted file. Takes (str) path_name as argument. - Returns dict with boolean status, str msg, dict conf + Returns (dict) with (bool) status, (str) msg, (dict) conf """ from json import load try: with open(path_name) as json_file: conf = load(json_file) - return {"status": True, "msg": f"Read data from file: {path_name}", "conf": conf} - except (IOError, ValueError, EOFError, TypeError) as e: - logging.error(str(e)) - return {"status": False, "msg": str(e)} + return {"status": True, "msg": f"Read from file: {path_name}", "conf": conf} + except (IOError, ValueError, EOFError, TypeError) as error: + logging.error(str(error)) + return {"status": False, "msg": str(error)} except: - logging.error(f"Could not read file: {file_name}") + logging.error("Could not read file: %s", path_name) return {"status": False, "msg": f"Could not read file: {path_name}"} diff --git a/src/web/pi_cmds.py b/src/web/pi_cmds.py index 61554b96..3c1ddfa5 100644 --- a/src/web/pi_cmds.py +++ b/src/web/pi_cmds.py @@ -1,8 +1,15 @@ +""" +Module for methods controlling and getting information about the Pi's Linux system +""" + import subprocess def systemd_service(service, action): - # start/stop/restart + """ + Takes (str) service and (str) action + Action can be one of start/stop/restart + """ return ( subprocess.run(["sudo", "/bin/systemctl", action, service]).returncode == 0 @@ -10,16 +17,27 @@ def systemd_service(service, action): def reboot_pi(): + """ + Reboots the Pi system + """ subprocess.Popen(["sudo", "reboot"]) return True def shutdown_pi(): + """ + Shuts down the Pi system + """ subprocess.Popen(["sudo", "shutdown", "-h", "now"]) return True def running_env(): + """ + Returns (str) git and (str) env + git contains the git hash of the checked out code + env is the various system information where this app is running + """ ra_git_version = ( subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True) .stdout.decode("utf-8") @@ -35,7 +53,7 @@ def running_env(): def running_netatalk(): """ - Returns int afpd, which is the number of afpd processes currently running + Returns (int) afpd, which is the number of afpd processes currently running """ process = subprocess.run(["ps", "aux"], capture_output=True) output = process.stdout.decode("utf-8") @@ -45,6 +63,9 @@ def running_netatalk(): def is_bridge_setup(): + """ + Returns (bool) True if the rascsi_bridge network interface exists + """ process = subprocess.run(["brctl", "show"], capture_output=True) output = process.stdout.decode("utf-8") if "rascsi_bridge" in output: @@ -53,6 +74,10 @@ def is_bridge_setup(): def disk_space(): + """ + Returns a (dict) with (int) total (int) used (int) free + This is the disk space information of the volume where this app is running + """ from shutil import disk_usage total, used, free = disk_usage(__file__) return {"total": total, "used": used, "free": free} diff --git a/src/web/ractl_cmds.py b/src/web/ractl_cmds.py index ab03ddf0..aad3ccae 100644 --- a/src/web/ractl_cmds.py +++ b/src/web/ractl_cmds.py @@ -1,6 +1,9 @@ -import logging +""" +Module for commands sent to the RaSCSI backend service. +""" -from settings import * +from settings import REMOVABLE_DEVICE_TYPES +from socket_cmds import send_pb_command import rascsi_interface_pb2 as proto @@ -8,12 +11,12 @@ def get_server_info(): """ Sends a SERVER_INFO command to the server. Returns a dict with: - - boolean status - - str version (RaSCSI version number) - - list of str log_levels (the log levels RaSCSI supports) - - str current_log_level - - list of int reserved_ids - - 5 distinct lists of strs with file endings recognized by RaSCSI + - (bool) status + - (str) version (RaSCSI version number) + - (list) of (str) log_levels (the log levels RaSCSI supports) + - (str) current_log_level + - (list) of (int) reserved_ids + - 5 distinct (list)s of (str)s with file endings recognized by RaSCSI """ command = proto.PbCommand() command.operation = proto.PbOperation.SERVER_INFO @@ -36,39 +39,39 @@ def get_server_info(): scrm = [] scmo = [] sccd = [] - for m in mappings: - if mappings[m] == proto.PbDeviceType.SAHD: - sahd.append(m) - elif mappings[m] == proto.PbDeviceType.SCHD: - schd.append(m) - elif mappings[m] == proto.PbDeviceType.SCRM: - scrm.append(m) - elif mappings[m] == proto.PbDeviceType.SCMO: - scmo.append(m) - elif mappings[m] == proto.PbDeviceType.SCCD: - sccd.append(m) + for dtype in mappings: + if mappings[dtype] == proto.PbDeviceType.SAHD: + sahd.append(dtype) + elif mappings[dtype] == proto.PbDeviceType.SCHD: + schd.append(dtype) + elif mappings[dtype] == proto.PbDeviceType.SCRM: + scrm.append(dtype) + elif mappings[dtype] == proto.PbDeviceType.SCMO: + scmo.append(dtype) + elif mappings[dtype] == proto.PbDeviceType.SCCD: + sccd.append(dtype) return { - "status": result.status, - "version": version, - "log_levels": log_levels, - "current_log_level": current_log_level, - "reserved_ids": reserved_ids, - "image_dir": image_dir, - "sahd": sahd, - "schd": schd, - "scrm": scrm, - "scmo": scmo, - "sccd": sccd, - } + "status": result.status, + "version": version, + "log_levels": log_levels, + "current_log_level": current_log_level, + "reserved_ids": reserved_ids, + "image_dir": image_dir, + "sahd": sahd, + "schd": schd, + "scrm": scrm, + "scmo": scmo, + "sccd": sccd, + } def get_network_info(): """ Sends a NETWORK_INTERFACES_INFO command to the server. Returns a dict with: - - boolean status - - list of str ifs (network interfaces detected by RaSCSI) + - (bool) status + - (list) of (str) ifs (network interfaces detected by RaSCSI) """ command = proto.PbCommand() command.operation = proto.PbOperation.NETWORK_INTERFACES_INFO @@ -84,8 +87,8 @@ def get_device_types(): """ Sends a DEVICE_TYPES_INFO command to the server. Returns a dict with: - - boolean status - - list of str device_types (device types that RaSCSI supports, ex. SCHD, SCCD, etc) + - (bool) status + - (list) of (str) device_types (device types that RaSCSI supports, ex. SCHD, SCCD, etc) """ command = proto.PbCommand() command.operation = proto.PbOperation.DEVICE_TYPES_INFO @@ -94,44 +97,20 @@ def get_device_types(): result = proto.PbResult() result.ParseFromString(data) device_types = [] - for t in result.device_types_info.properties: - device_types.append(proto.PbDeviceType.Name(t.type)) + for prop in result.device_types_info.properties: + device_types.append(proto.PbDeviceType.Name(prop.type)) return {"status": result.status, "device_types": device_types} - - -def get_valid_scsi_ids(devices, reserved_ids): - """ - Takes a list of dicts devices, and list of ints reserved_ids. - Returns: - - list of ints valid_ids, which are the SCSI ids that are not reserved - - int recommended_id, which is the id that the Web UI should default to recommend - """ - occupied_ids = [] - for d in devices: - occupied_ids.append(d["id"]) - - unoccupied_ids = [i for i in list(range(8)) if i not in reserved_ids + occupied_ids] - unoccupied_ids.sort() - valid_ids = [i for i in list(range(8)) if i not in reserved_ids] - valid_ids.sort(reverse=True) - - if len(unoccupied_ids) > 0: - recommended_id = unoccupied_ids[-1] - else: - recommended_id = occupied_ids.pop(0) - - return valid_ids, recommended_id def attach_image(scsi_id, **kwargs): """ - Takes int scsi_id and kwargs containing 0 or more device properties + Takes (int) scsi_id and kwargs containing 0 or more device properties If the current attached device is a removable device wihout media inserted, this sends a INJECT command to the server. If there is no currently attached device, this sends the ATTACH command to the server. - Returns boolean status and str msg + Returns (bool) status and (str) msg """ command = proto.PbCommand() @@ -149,19 +128,21 @@ def attach_image(scsi_id, **kwargs): devices.params["file"] = kwargs["image"] # Handling the inserting of media into an attached removable type device - device_type = kwargs.get("device_type", None) + device_type = kwargs.get("device_type", None) currently_attached = list_devices(scsi_id, kwargs.get("unit"))["device_list"] - if len(currently_attached) > 0: - current_type = currently_attached[0]["device_type"] + if currently_attached: + current_type = currently_attached[0]["device_type"] else: current_type = None if device_type in REMOVABLE_DEVICE_TYPES and current_type in REMOVABLE_DEVICE_TYPES: if current_type != device_type: - return {"status": False, "msg": f"Cannot insert an image for \ - {device_type} into a {current_type} device."} - else: - command.operation = proto.PbOperation.INSERT + return { + "status": False, + "msg": "Cannot insert an image for " + device_type + \ + " into a " + current_type + " device." + } + command.operation = proto.PbOperation.INSERT # Handling attaching a new device else: command.operation = proto.PbOperation.ATTACH @@ -169,13 +150,13 @@ def attach_image(scsi_id, **kwargs): if kwargs["interfaces"] not in [None, ""]: devices.params["interfaces"] = kwargs["interfaces"] if "vendor" in kwargs.keys(): - if kwargs["vendor"] != None: + if kwargs["vendor"] is not None: devices.vendor = kwargs["vendor"] if "product" in kwargs.keys(): - if kwargs["product"] != None: + if kwargs["product"] is not None: devices.product = kwargs["product"] if "revision" in kwargs.keys(): - if kwargs["revision"] != None: + if kwargs["revision"] is not None: devices.revision = kwargs["revision"] if "block_size" in kwargs.keys(): if kwargs["block_size"] not in [None, ""]: @@ -189,16 +170,16 @@ def attach_image(scsi_id, **kwargs): return {"status": result.status, "msg": result.msg} -def detach_by_id(scsi_id, un=None): +def detach_by_id(scsi_id, unit=None): """ - Takes int scsi_id and optional int un + Takes (int) scsi_id and optional (int) unit. Sends a DETACH command to the server. - Returns boolean status and str msg. + Returns (bool) status and (str) msg. """ devices = proto.PbDeviceDefinition() devices.id = int(scsi_id) - if un != None: - devices.unit = int(un) + if unit is not None: + devices.unit = int(unit) command = proto.PbCommand() command.operation = proto.PbOperation.DETACH @@ -213,7 +194,7 @@ def detach_by_id(scsi_id, un=None): def detach_all(): """ Sends a DETACH_ALL command to the server. - Returns boolean status and str msg. + Returns (bool) status and (str) msg. """ command = proto.PbCommand() command.operation = proto.PbOperation.DETACH_ALL @@ -224,16 +205,16 @@ def detach_all(): return {"status": result.status, "msg": result.msg} -def eject_by_id(scsi_id, un=None): +def eject_by_id(scsi_id, unit=None): """ - Takes int scsi_id and optional int un. + Takes (int) scsi_id and optional (int) unit. Sends an EJECT command to the server. - Returns boolean status and str msg. + Returns (bool) status and (str) msg. """ devices = proto.PbDeviceDefinition() devices.id = int(scsi_id) - if un != None: - devices.unit = int(un) + if unit is not None: + devices.unit = int(unit) command = proto.PbCommand() command.operation = proto.PbOperation.EJECT @@ -245,14 +226,14 @@ def eject_by_id(scsi_id, un=None): return {"status": result.status, "msg": result.msg} -def list_devices(scsi_id=None, un=None): +def list_devices(scsi_id=None, unit=None): """ - Takes optional int scsi_id and optional int un. + Takes optional (int) scsi_id and optional (int) unit. Sends a DEVICES_INFO command to the server. - If no scsi_id is provided, returns a list of dicts of all attached devices. - If scsi_id is is provided, returns a list of one dict for the given device. - If no attached device is found, returns an empty list. - Returns boolean status, list of dicts device_list + If no scsi_id is provided, returns a (list) of (dict)s of all attached devices. + If scsi_id is is provided, returns a (list) of one (dict) for the given device. + If no attached device is found, returns an empty (list). + Returns (bool) status, (list) of dicts device_list """ from os import path command = proto.PbCommand() @@ -260,11 +241,11 @@ def list_devices(scsi_id=None, un=None): # If method is called with scsi_id parameter, return the info on those devices # Otherwise, return the info on all attached devices - if scsi_id != None: + if scsi_id is not None: device = proto.PbDeviceDefinition() device.id = int(scsi_id) - if un != None: - device.unit = int(un) + if unit is not None: + device.unit = int(unit) command.devices.append(device) data = send_pb_command(command.SerializeToString()) @@ -272,89 +253,63 @@ def list_devices(scsi_id=None, un=None): result.ParseFromString(data) device_list = [] - n = 0 - # Return an empty list if no devices are attached - if len(result.devices_info.devices) == 0: + # Return an empty (list) if no devices are attached + if not result.devices_info.devices: return {"status": False, "device_list": []} - while n < len(result.devices_info.devices): - did = result.devices_info.devices[n].id - dun = result.devices_info.devices[n].unit - dtype = proto.PbDeviceType.Name(result.devices_info.devices[n].type) - dstat = result.devices_info.devices[n].status - dprop = result.devices_info.devices[n].properties + i = 0 + while i < len(result.devices_info.devices): + did = result.devices_info.devices[i].id + dunit = result.devices_info.devices[i].unit + dtype = proto.PbDeviceType.Name(result.devices_info.devices[i].type) + dstat = result.devices_info.devices[i].status + dprop = result.devices_info.devices[i].properties # Building the status string - # TODO: This formatting should probably be moved elsewhere dstat_msg = [] - if dprop.read_only == True: + if dprop.read_only: dstat_msg.append("Read-Only") - if dstat.protected == True and dprop.protectable == True: + if dstat.protected and dprop.protectable: dstat_msg.append("Write-Protected") - if dstat.removed == True and dprop.removable == True: + if dstat.removed and dprop.removable: dstat_msg.append("No Media") - if dstat.locked == True and dprop.lockable == True: + if dstat.locked and dprop.lockable: dstat_msg.append("Locked") - dpath = result.devices_info.devices[n].file.name + dpath = result.devices_info.devices[i].file.name dfile = path.basename(dpath) - dparam = result.devices_info.devices[n].params - dven = result.devices_info.devices[n].vendor - dprod = result.devices_info.devices[n].product - drev = result.devices_info.devices[n].revision - dblock = result.devices_info.devices[n].block_size - dsize = int(result.devices_info.devices[n].block_count) * int(dblock) + dparam = result.devices_info.devices[i].params + dven = result.devices_info.devices[i].vendor + dprod = result.devices_info.devices[i].product + drev = result.devices_info.devices[i].revision + dblock = result.devices_info.devices[i].block_size + dsize = int(result.devices_info.devices[i].block_count) * int(dblock) - device_list.append( - { - "id": did, - "un": dun, - "device_type": dtype, - "status": ", ".join(dstat_msg), - "image": dpath, - "file": dfile, - "params": dparam, - "vendor": dven, - "product": dprod, - "revision": drev, - "block_size": dblock, - "size": dsize, - } - ) - n += 1 + device_list.append({ + "id": did, + "un": dunit, + "device_type": dtype, + "status": ", ".join(dstat_msg), + "image": dpath, + "file": dfile, + "params": dparam, + "vendor": dven, + "product": dprod, + "revision": drev, + "block_size": dblock, + "size": dsize, + }) + i += 1 - return {"status": True, "device_list": device_list} - - -def sort_and_format_devices(devices): - """ - Takes a list of dicts devices and returns a list of dicts. - Sorts by SCSI ID acending (0 to 7). - For SCSI IDs where no device is attached, inject a dict with placeholder text. - """ - occupied_ids = [] - for d in devices: - occupied_ids.append(d["id"]) - - formatted_devices = devices - - # Add padding devices and sort the list - for id in range(8): - if id not in occupied_ids: - formatted_devices.append({"id": id, "device_type": "-", \ - "status": "-", "file": "-", "product": "-"}) - # Sort list of devices by id - formatted_devices.sort(key=lambda dic: str(dic["id"])) - - return formatted_devices + return {"status": result.status, "msg": result.msg, "device_list": device_list} def set_log_level(log_level): """ Sends a LOG_LEVEL command to the server. - Takes str log_level as an argument. - Returns boolean status and str msg. + Takes (str) log_level as an argument. + Returns (bool) status and (str) msg. """ command = proto.PbCommand() command.operation = proto.PbOperation.LOG_LEVEL @@ -364,89 +319,3 @@ def set_log_level(log_level): result = proto.PbResult() result.ParseFromString(data) return {"status": result.status, "msg": result.msg} - - -def send_pb_command(payload): - """ - Takes a str containing a serialized protobuf as argument. - Establishes a socket connection with RaSCSI. - """ - # Host and port number where rascsi is listening for socket connections - HOST = 'localhost' - PORT = 6868 - - counter = 0 - tries = 100 - error_msg = "" - - import socket - while counter < tries: - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect((HOST, PORT)) - return send_over_socket(s, payload) - except socket.error as error: - counter += 1 - logging.warning("The RaSCSI service is not responding - attempt " + \ - str(counter) + "/" + str(tries)) - error_msg = str(error) - - logging.error(error_msg) - - # After failing all attempts, throw a 404 error - from flask import abort - abort(404, "Failed to connect to RaSCSI at " + str(HOST) + ":" + str(PORT) + \ - " with error: " + error_msg + ". Is the RaSCSI service running?") - - -def send_over_socket(s, payload): - """ - Takes a socket object and str payload with serialized protobuf. - Sends payload to RaSCSI over socket and captures the response. - Tries to extract and interpret the protobuf header to get response size. - Reads data from socket in 2048 bytes chunks until all data is received. - - """ - from struct import pack, unpack - - # Sending the magic word "RASCSI" to authenticate with the server - s.send(b"RASCSI") - # Prepending a little endian 32bit header with the message size - s.send(pack("= 4: - # Extracting the response header to get the length of the response message - response_length = unpack("= 4: + # Extracting the response header to get the length of the response message + response_length = unpack(" /dev/null ; then ERROR=1 fi if [ $ERROR = 1 ] ; then - echo - echo "Fix errors and re-run ./start.sh" - exit 1 + echo + echo "Fix errors and re-run ./start.sh" + exit 1 fi if ! test -e venv; then - echo "Creating python venv for web server" - python3 -m venv venv - echo "Activating venv" - source venv/bin/activate - echo "Installing requirements.txt" - pip install wheel - pip install -r requirements.txt - git rev-parse HEAD > current + echo "Creating python venv for web server" + python3 -m venv venv + echo "Activating venv" + source venv/bin/activate + echo "Installing requirements.txt" + pip install wheel + pip install -r requirements.txt + git rev-parse HEAD > current fi source venv/bin/activate # Detect if someone updates - we need to re-run pip install. if ! test -e current; then - git rev-parse > current + git rev-parse > current else - if [ "$(cat current)" != "$(git rev-parse HEAD)" ]; then - echo "New version detected, updating requirements.txt" - pip install -r requirements.txt - git rev-parse HEAD > current - fi + if [ "$(cat current)" != "$(git rev-parse HEAD)" ]; then + echo "New version detected, updating requirements.txt" + pip install -r requirements.txt + git rev-parse HEAD > current + fi fi echo "Starting web server..." diff --git a/src/web/templates/base.html b/src/web/templates/base.html index 751040cb..3bea5567 100644 --- a/src/web/templates/base.html +++ b/src/web/templates/base.html @@ -1,4 +1,6 @@ + + RaSCSI Control Page @@ -24,7 +26,9 @@ + +
Service Running @@ -43,17 +47,18 @@
{% for category, message in get_flashed_messages(with_categories=true) %} {% if category == "stdout" or category == "stderr" %} -
{{message}}
+
{{ message }}
{% else %} -
{{ message }}
+
{{ message }}
{% endif %} {% endfor %}
- {% block content %}{% endblock %} + {% block content %}{% endblock content %}
+ diff --git a/src/web/templates/drives.html b/src/web/templates/drives.html index 3217ed53..3cd8bf6f 100644 --- a/src/web/templates/drives.html +++ b/src/web/templates/drives.html @@ -1,141 +1,141 @@ {% extends "base.html" %} - {% block content %} -

Cancel

-

Disclaimer

-

These device profiles are provided as-is with no guarantee to work on the systems mentioned. You may need appropirate device drivers and/or configuration parameters. If you have improvement suggestions or success stories to share we would love to hear from you, so please connect with us at GitHub or Discord!

-

Hard Drives

+{% block content %} +

Cancel

+

Disclaimer

+

These device profiles are provided as-is with no guarantee to work on the systems mentioned. You may need appropirate device drivers and/or configuration parameters. If you have improvement suggestions or success stories to share we would love to hear from you, so please connect with us at GitHub or Discord!

+

Hard Drives

- - - - - - - - - - {% for hd in hd_conf %} - - - - - - - - {% endfor %} - -
NameSize (MB)DescriptionRef.Action
{{hd.name}}{{hd.size_mb}}{{hd.description}} - {% if hd.url != "" %} - Link - {% else %} - - - {% endif %} - -
- - - - - - - - - .{{hd.file_type}} - -
-
+ + + + + + + + + +{% for hd in hd_conf %} + + + + + + + +{% endfor %} + +
NameSize (MB)DescriptionRef.Action
{{ hd.name }}{{ hd.size_mb }}{{ hd.description }} + {% if hd.url != "" %} + Link + {% else %} + - + {% endif %} + +
+ + + + + + + + + .{{ hd.file_type }} + +
+
-
+
-

CD-ROM Drives

-

This will create a properties file for the given CD-ROM image. No new image file will be created.

- - - - - - - - - - {% for cd in cd_conf %} - - - - - - - - {% endfor %} - -
NameSize (MB)DescriptionRef.Action
{{cd.name}}{{cd.size_mb}}{{cd.description}} - {% if cd.url != "" %} - Link - {% else %} - - - {% endif %} - -
- - - - - - - -
-
+

CD-ROM Drives

+

This will create a properties file for the given CD-ROM image. No new image file will be created.

+ + + + + + + + + +{% for cd in cd_conf %} + + + + + + + +{% endfor %} + +
NameSize (MB)DescriptionRef.Action
{{ cd.name }}{{ cd.size_mb }}{{ cd.description }} + {% if cd.url != "" %} + Link + {% else %} + - + {% endif %} + +
+ + + + + + + +
+
-
+
-

Removable Drives

- - - - - - - - - - {% for rm in rm_conf %} - - - - - - - - {% endfor %} - -
NameSize (MB)DescriptionRef.Action
{{rm.name}}{{rm.size_mb}}{{rm.description}} - {% if rm.url != "" %} - Link - {% else %} - - - {% endif %} - -
- - - - - - - - - .{{rm.file_type}} - -
-
-

Available disk space on the Pi: {{free_disk}} MB

-

Cancel

+

Removable Drives

+ + + + + + + + + +{% for rm in rm_conf %} + + + + + + + +{% endfor %} + +
NameSize (MB)DescriptionRef.Action
{{ rm.name }}{{ rm.size_mb }}{{ rm.description }} + {% if rm.url != "" %} + Link + {% else %} + - + {% endif %} + +
+ + + + + + + + + .{{ rm.file_type }} + +
+
+

Available disk space on the Pi: {{ free_disk }} MB

+

Cancel

-{% endblock %} +{% endblock content %} diff --git a/src/web/templates/index.html b/src/web/templates/index.html index 169ecbe7..661d427d 100644 --- a/src/web/templates/index.html +++ b/src/web/templates/index.html @@ -1,44 +1,47 @@ {% extends "base.html" %} +{% block content %} - {% block content %} -
- Current RaSCSI Configuration +
+ + Current RaSCSI Configuration +
    -
  • Displays the currently attached devices for each available SCSI ID.
  • -
  • Save and load device configurations into {{cfg_dir}}
  • -
  • The default configuration will be loaded when the Web UI starts up, if available.
  • +
  • Displays the currently attached devices for each available SCSI ID.
  • +
  • Save and load device configurations into {{ CFG_DIR }}
  • +
  • The default configuration will be loaded when the Web UI starts up, if available.
-
+
-

-

- - - -
-

-

-

- - -
-

+

+ + + +

- - +

+ + +

+ +
+ - {% if luns %} + {% if units %} - {% endif %} + {% endif %} @@ -48,50 +51,45 @@ {% for device in devices %} {% if device["id"] not in reserved_scsi_ids %} - - {% if luns %} - - {% endif %} - - - - {% if device.vendor == "RaSCSI" %} - - {% else %} - - {% endif %} + + {% if units %} + + {% endif %} + + + + {% if device.vendor == "RaSCSI" %} + + {% else %} + + {% endif %} {% else %} - - {% if luns %} + + {% if units %} - {% endif %} + {% endif %} @@ -100,28 +98,30 @@ {% endif %} {% endfor %} - -
IDLUNType Status File
{{device.id}}{{device.un}}{{device.device_type}}{{device.status}}{{device.file}}{{device.product}}{{device.vendor}} {{device.product}}{{ device.id }}{{ device.un }}{{ device.device_type }}{{ device.status }}{{ device.file }}{{ device.product }}{{ device.vendor }} {{ device.product }} - {% if device.device_type != "-" %} - {% if device.device_type in removable_device_types and "No Media" not in device.status %} + {% if device.device_type != "-" %} + {% if device.device_type in REMOVABLE_DEVICE_TYPES and "No Media" not in device.status %}
- - - -
-
- - - + + +
{% else %} -
- - - + + + +
+ {% endif %}
- - - + + +
- {% endif %} - {% endif %} + {% endif %}
{{device.id}}{{ device.id }} Reserved ID
-

-

- -
-

+ + -
+

+ +

-
- Image File Management +
+ +
+ + Image File Management +
    -
  • Manage image files in the active RaSCSI image directory: {{base_dir}}
  • -
  • Select a valid SCSI ID and LUN to attach to. Unless you know what you're doing, always use LUN 0.
  • -
  • If RaSCSI was unable to detect the device type associated with the image, you can choose the type from the dropdown.
  • -
  • Types: SAHD = SASI HDD | SCHD = SCSI HDD | SCRM = Removable | SCMO = Magneto-Optical | SCCD = CD-ROM | SCBR = Host Bridge | SCDP = DaynaPORT
  • +
  • Manage image files in the active RaSCSI image directory: {{ base_dir }}
  • +
  • Select a valid SCSI ID and LUN to attach to. Unless you know what you're doing, always use LUN 0. +
  • +
  • If RaSCSI was unable to detect the device type associated with the image, you can choose the type from the dropdown.
  • +
  • Types: SAHD = SASI HDD | SCHD = SCSI HDD | SCRM = Removable | SCMO = Magneto-Optical | SCCD = CD-ROM | SCBR = Host Bridge | SCDP = DaynaPORT
-
+
- - +
+ @@ -132,365 +132,420 @@ {% if file["prop"] %} - {% elif file["zip"] %} + {% elif file["zip"] %} - {% else %} - - {% endif %} + {% else %} + + {% endif %} - {% endfor %} - -
File Size
- {{file["name"]}} + + {{ file["name"] }} +
    {% for key in file["prop"] %} -
  • {{key}}: {{file['prop'][key]}}
  • +
  • {{ key }}: {{ file['prop'][key] }}
  • {% endfor %}
-
+
- {{file["name"]}} + + {{ file["name"] }} +
    {% for member in file["zip"] %} -
  • - -
    - - - -
  • +
  • + +
    + + + +
    +
  • {% endfor %}
-
+
{{file["name"]}}{{ file["name"] }}
- - + +
- {% if file["name"] in attached_images %} -
Attached!
- {% else %} - {% if file["name"].lower().endswith(archive_file_suffix) %} -
- - -
- {% else %} -
- - - +
+ {% if file["name"] in attached_images %} +
+ Attached! +
+ {% else %} + {% if file["name"].lower().endswith(archive_file_suffix) %} + + + + + {% else %} +
+ + + - - - {% if file["detected_type"] != "UNDEFINED" %} - - {{file["detected_type"]}} - {% else %} - - - {% endif %} + + + {% if file["detected_type"] != "UNDEFINED" %} + + {{ file["detected_type"] }} + {% else %} + + + {% endif %}
- - + +
- {% endif %} + {% endif %}
-

Available disk space on the Pi: {{free_disk}} MB

+ + +

Available disk space on the Pi: {{ free_disk }} MB

-
+
-
- Attach Ethernet Adapter +
+ + Attach Ethernet Adapter +
    -
  • Emulates a SCSI DaynaPORT Ethernet Adapter. Host drivers and configuration required.
  • -
  • If you have a DHCP setup, choose only the interface, and ignore the Static IP fields when attaching.
  • -
  • Configure network forwarding by running easyinstall.sh, or follow the manual steps in the wiki.
  • - {% if bridge_configured %} -
  • The rascsi_bridge interface is active and ready to be used by DaynaPORT!
  • - {% endif %} +
  • Emulates a SCSI DaynaPORT Ethernet Adapter. Host drivers and configuration required. +
  • +
  • If you have a DHCP setup, choose only the interface, and ignore the Static IP fields when attaching.
  • +
  • Configure network forwarding by running easyinstall.sh, or follow the manual steps in the wiki. +
  • +
  • {% if bridge_configured %}
  • +
  • The rascsi_bridge interface is active and ready to be used by DaynaPORT!
  • +
  • {% endif %}
-
- - - - -
-
- - - - - - - - -
-
+
+ + + + +
+
+ + + + + + + + +
+
-
- -
- Upload File +
+
+ + Upload File +
    -
  • Uploads file to {{base_dir}}. The largest file size accepted is {{max_file_size}} MB.
  • -
  • For unrecognized file types, try renaming hard drive images to '.hds' and CD-ROM images to '.iso' before uploading.
  • -
  • Recognized file types: {{valid_file_suffix}}
  • +
  • Uploads file to {{ base_dir }}. The largest file size accepted is {{ max_file_size }} MB.
  • +
  • For unrecognized file types, try renaming hard drive images to '.hds' and CD-ROM images to '.iso' before uploading.
  • +
  • Recognized file types: {{ valid_file_suffix }}
-
+
- - - - -
-
-
-
- + -
+
-
- Download File to Images +
+ + Download File to Images +
    -
  • Given a URL, download that file to the {{base_dir}} directory.
  • +
  • Given a URL, download that file to the {{ base_dir }} directory.
-
+
- - - - -
-
- - - -
-
+ + + + +
+
+ + + +
+
-
+
-
- Download File to AppleShare +
+ + Download File to AppleShare +
    -
  • Given a URL, download that file to the {{afp_dir}} directory and share it over AFP.
  • -
  • Manage the files you download here through AppleShare on your vintage Mac.
  • -
  • Requires Netatalk to be installed and configured correctly for your network.
  • +
  • Given a URL, download that file to the {{ AFP_DIR }} directory and share it over AFP.
  • +
  • Manage the files you download here through AppleShare on your vintage Mac.
  • +
  • Requires Netatalk to be installed and configured correctly for your network. +
-
- {% if netatalk_configured %} - - - - -
-
- - - -
-
- {% if netatalk_configured == 1 %} -

The AppleShare server is running. No active connections

- {% elif netatalk_configured == 2 %} -

{{netatalk_configured - 1}} active AFP connection

- {% elif netatalk_configured > 2 %} -

{{netatalk_configured - 1}} active AFP connections

- {% endif %} - {% else %} -

Install Netatalk to use the AppleTalk File Server. - {% endif %} +

-
+{% if netatalk_configured %} + + + + +
+
+ + + +
+
-
- Download File from Web and Create HFS CD (Macintosh) +{% if netatalk_configured == 1 %} +

The AppleShare server is running. No active connections

+{% elif netatalk_configured == 2 %} +

{{ netatalk_configured - 1 }} active AFP connection

+{% elif netatalk_configured > 2 %} +

{{ netatalk_configured - 1 }} active AFP connections

+{% endif %} +{% else %} +

Install Netatalk to use the AppleShare File Server.

+{% endif %} + +
+ +
+ + Download File and Create HFS CD (Macintosh) +
    -
  • Given a URL this will download a file, create a HFS iso, and mount it on the SCSI ID given.
  • -
  • Requires a compatible CD-ROM driver installed on the target system.
  • +
  • Given a URL this will download a file, create a HFS iso, and mount it on the SCSI ID given.
  • +
  • Requires a compatible CD-ROM driver installed on the target system. +
-
+
+ + + + +
+ +
+ + + + +
+
- - - - -
- -
- - - - -
-
+
-
- -
- Create Empty Disk Image File +
+ + Create Empty Disk Image File +
    -
  • The Generic image type is recommended for most systems
  • -
  • APPLE GENUINE and NEC GENUINE image types will make RaSCSI masquerade as a particular drive type that are recognized by Mac and PC98 systems, respectively.
  • -
  • SASI images should only be used on early X68000 or UNIX workstation systems that use this pre-SCSI standard.
  • +
  • The Generic image type is recommended for most systems
  • +
  • APPLE GENUINE and NEC GENUINE image types will make RaSCSI masquerade as a particular drive type that are recognized by Mac and PC98 systems, respectively.
  • +
  • SASI images should only be used on early X68000 or UNIX workstation systems that use this pre-SCSI standard.
-
- - - - -
-
- - - - - - - -
-
+
+ + + + +
+
+ + + + + + + +
+
-
+
-
- Create Named Drive +
+ + Create Named Drive +
    -
  • Here you can create pairs of images and properties files from a list of real-life drives.
  • -
  • This will make RaSCSI use certain vendor strings and block sizes that may improve compatibility with certain systems
  • +
  • Here you can create pairs of images and properties files from a list of real-life drives.
  • +
  • This will make RaSCSI use certain vendor strings and block sizes that may improve compatibility with certain systems
-
+
+

Create a named disk image that mimics real-life drives

-

Create a named disk image that mimics real-life drives

+
-
- -
- Logging +
+ + Logging +
    -
  • Get a certain number of lines of service logs with the given scope.
  • +
  • Get a certain number of lines of service logs with the given scope.
-
+
+ + + + +
+
+ + + + + +
+
- - - - -
-
- - - - - -
-
+
-
- -
- Server Log Level +
+ + Server Log Level +
    -
  • Change the log level of the RaSCSI backend service.
  • -
  • The dropdown will indicate the current log level.
  • +
  • Change the log level of the RaSCSI backend service.
  • +
  • The dropdown will indicate the current log level.
-
+
+ + + + +
+
+ + + +
+
- - - - -
-
- - - -
-
+
-
- -
- Raspberry Pi Operations +
+ + Raspberry Pi Operations +
    -
  • Issue reboot or shutdown commands to the Raspberr Pi.
  • -
  • You can also restart the RaSCSI backend service here.
  • +
  • Issue reboot or shutdown commands to the Raspberr Pi.
  • +
  • You can also restart the RaSCSI backend service here.
-
+
+ + + + + + +
+
+ +
+
+
+ +
+
+
+ +
+
- - - - - - - -
-
- -
-
-
- -
-
-
- -
-
- -{% endblock %} +{% endblock content %} diff --git a/src/web/web.py b/src/web/web.py index c8085af0..0ca9120e 100644 --- a/src/web/web.py +++ b/src/web/web.py @@ -1,4 +1,10 @@ +""" +Module for the Flask app rendering and endpoints +""" + import logging +from pathlib import Path + from flask import ( Flask, render_template, @@ -12,8 +18,8 @@ from flask import ( ) from file_cmds import ( - list_config_files, list_images, + list_config_files, create_new_image, download_file_to_iso, delete_image, @@ -37,52 +43,67 @@ from pi_cmds import ( from ractl_cmds import ( attach_image, list_devices, - sort_and_format_devices, detach_by_id, eject_by_id, - get_valid_scsi_ids, detach_all, get_server_info, get_network_info, get_device_types, set_log_level, ) -from settings import * +from device_utils import ( + sort_and_format_devices, + get_valid_scsi_ids, +) +from settings import ( + CFG_DIR, + AFP_DIR, + MAX_FILE_SIZE, + ARCHIVE_FILE_SUFFIX, + CONFIG_FILE_SUFFIX, + PROPERTIES_SUFFIX, + DEFAULT_CONFIG, + DRIVE_PROPERTIES_FILE, + REMOVABLE_DEVICE_TYPES, +) -app = Flask(__name__) +APP = Flask(__name__) -@app.route("/") +@APP.route("/") def index(): + """ + Sets up data structures for and renders the index page + """ server_info = get_server_info() disk = disk_space() devices = list_devices() - device_types=get_device_types() + device_types = get_device_types() files = list_images() config_files = list_config_files() - sorted_image_files = sorted(files["files"], key = lambda x: x["name"].lower()) - sorted_config_files = sorted(config_files, key = lambda x: x.lower()) + sorted_image_files = sorted(files["files"], key=lambda x: x["name"].lower()) + sorted_config_files = sorted(config_files, key=lambda x: x.lower()) - from pathlib import Path attached_images = [] - luns = 0 - for d in devices["device_list"]: - attached_images.append(Path(d["image"]).name) - luns += int(d["un"]) + units = 0 + # If there are more than 0 logical unit numbers, display in the Web UI + for device in devices["device_list"]: + attached_images.append(Path(device["image"]).name) + units += int(device["un"]) reserved_scsi_ids = server_info["reserved_ids"] scsi_ids, recommended_id = get_valid_scsi_ids(devices["device_list"], reserved_scsi_ids) formatted_devices = sort_and_format_devices(devices["device_list"]) valid_file_suffix = "."+", .".join( - server_info["sahd"] + - server_info["schd"] + - server_info["scrm"] + - server_info["scmo"] + - server_info["sccd"] + - list(ARCHIVE_FILE_SUFFIX) - ) + server_info["sahd"] + + server_info["schd"] + + server_info["scrm"] + + server_info["scmo"] + + server_info["sccd"] + + [ARCHIVE_FILE_SUFFIX] + ) return render_template( "index.html", @@ -92,14 +113,14 @@ def index(): files=sorted_image_files, config_files=sorted_config_files, base_dir=server_info["image_dir"], - cfg_dir=cfg_dir, - afp_dir=afp_dir, + CFG_DIR=CFG_DIR, + AFP_DIR=AFP_DIR, scsi_ids=scsi_ids, recommended_id=recommended_id, attached_images=attached_images, - luns=luns, + units=units, reserved_scsi_ids=reserved_scsi_ids, - max_file_size=int(MAX_FILE_SIZE / 1024 / 1024), + max_file_size=int(int(MAX_FILE_SIZE) / 1024 / 1024), running_env=running_env(), version=server_info["version"], log_levels=server_info["log_levels"], @@ -109,11 +130,11 @@ def index(): free_disk=int(disk["free"] / 1024 / 1024), valid_file_suffix=valid_file_suffix, archive_file_suffix=ARCHIVE_FILE_SUFFIX, - removable_device_types=REMOVABLE_DEVICE_TYPES, + REMOVABLE_DEVICE_TYPES=REMOVABLE_DEVICE_TYPES, ) -@app.route("/drive/list", methods=["GET"]) +@APP.route("/drive/list", methods=["GET"]) def drive_list(): """ Sets up the data structures and kicks off the rendering of the drive list page @@ -123,11 +144,10 @@ def drive_list(): # Reads the canonical drive properties into a dict # The file resides in the current dir of the web ui process - from pathlib import Path drive_properties = Path(DRIVE_PROPERTIES_FILE) if drive_properties.is_file(): process = read_drive_properties(str(drive_properties)) - if process["status"] == False: + if not process["status"]: flash(process["msg"], "error") return redirect(url_for("index")) conf = process["conf"] @@ -140,24 +160,24 @@ def drive_list(): rm_conf = [] from werkzeug.utils import secure_filename - for d in conf: - if d["device_type"] == "SCHD": - d["secure_name"] = secure_filename(d["name"]) - d["size_mb"] = "{:,.2f}".format(d["size"] / 1024 / 1024) - hd_conf.append(d) - elif d["device_type"] == "SCCD": - d["size_mb"] = "N/A" - cd_conf.append(d) - elif d["device_type"] == "SCRM": - d["secure_name"] = secure_filename(d["name"]) - d["size_mb"] = "{:,.2f}".format(d["size"] / 1024 / 1024) - rm_conf.append(d) + for device in conf: + if device["device_type"] == "SCHD": + device["secure_name"] = secure_filename(device["name"]) + device["size_mb"] = "{:,.2f}".format(device["size"] / 1024 / 1024) + hd_conf.append(device) + elif device["device_type"] == "SCCD": + device["size_mb"] = "N/A" + cd_conf.append(device) + elif device["device_type"] == "SCRM": + device["secure_name"] = secure_filename(device["name"]) + device["size_mb"] = "{:,.2f}".format(device["size"] / 1024 / 1024) + rm_conf.append(device) - files=list_images() - sorted_image_files = sorted(files["files"], key = lambda x: x["name"].lower()) - hd_conf = sorted(hd_conf, key = lambda x: x["name"].lower()) - cd_conf = sorted(cd_conf, key = lambda x: x["name"].lower()) - rm_conf = sorted(rm_conf, key = lambda x: x["name"].lower()) + files = list_images() + sorted_image_files = sorted(files["files"], key=lambda x: x["name"].lower()) + hd_conf = sorted(hd_conf, key=lambda x: x["name"].lower()) + cd_conf = sorted(cd_conf, key=lambda x: x["name"].lower()) + rm_conf = sorted(rm_conf, key=lambda x: x["name"].lower()) return render_template( "drives.html", @@ -173,13 +193,19 @@ def drive_list(): ) -@app.route('/pwa/') +@APP.route('/pwa/') def send_pwa_files(path): + """ + Sets up mobile web resources + """ return send_from_directory('pwa', path) -@app.route("/drive/create", methods=["POST"]) +@APP.route("/drive/create", methods=["POST"]) def drive_create(): + """ + Creates the image and properties file pair + """ vendor = request.form.get("vendor") product = request.form.get("product") revision = request.form.get("revision") @@ -190,29 +216,34 @@ def drive_create(): # Creating the image file process = create_new_image(file_name, file_type, size) - if process["status"] == True: - flash(f"Drive image file {file_name}.{file_type} created") - flash(process["msg"]) + if process["status"]: + flash(f"Created drive image file: {file_name}.{file_type}") else: - flash(f"Failed to create file {file_name}.{file_type}", "error") flash(process["msg"], "error") return redirect(url_for("index")) # Creating the drive properties file prop_file_name = f"{file_name}.{file_type}.{PROPERTIES_SUFFIX}" - properties = {"vendor": vendor, "product": product, \ - "revision": revision, "block_size": block_size} + properties = { + "vendor": vendor, + "product": product, + "revision": revision, + "block_size": block_size, + } process = write_drive_properties(prop_file_name, properties) - if process["status"] == True: - flash(f"Drive properties file {prop_file_name} created") - return redirect(url_for("index")) - else: - flash(f"Failed to create drive properties file {prop_file_name}", "error") + if process["status"]: + flash(process["msg"]) return redirect(url_for("index")) + flash(process['msg'], "error") + return redirect(url_for("index")) -@app.route("/drive/cdrom", methods=["POST"]) + +@APP.route("/drive/cdrom", methods=["POST"]) def drive_cdrom(): + """ + Creates a properties file for a CD-ROM image + """ vendor = request.form.get("vendor") product = request.form.get("product") revision = request.form.get("revision") @@ -222,63 +253,70 @@ def drive_cdrom(): # Creating the drive properties file file_name = f"{file_name}.{PROPERTIES_SUFFIX}" properties = { - "vendor": vendor, - "product": product, - "revision": revision, - "block_size": block_size, - } + "vendor": vendor, + "product": product, + "revision": revision, + "block_size": block_size, + } process = write_drive_properties(file_name, properties) - if process["status"] == True: - flash(f"Drive properties file {file_name} created") - return redirect(url_for("index")) - else: - flash(f"Failed to create drive properties file {file_name}", "error") - return redirect(url_for("index")) - - -@app.route("/config/save", methods=["POST"]) -def config_save(): - file_name = request.form.get("name") or "default" - file_name = f"{file_name}.json" - - process = write_config(file_name) - if process["status"] == True: - flash(f"Saved config to {file_name}!") + if process["status"]: flash(process["msg"]) return redirect(url_for("index")) - else: - flash(f"Failed to save config to {file_name}!", "error") - flash(process['msg'], "error") + + flash(process['msg'], "error") + return redirect(url_for("index")) + + +@APP.route("/config/save", methods=["POST"]) +def config_save(): + """ + Saves a config file to disk + """ + file_name = request.form.get("name") or "default" + file_name = f"{file_name}.{CONFIG_FILE_SUFFIX}" + + process = write_config(file_name) + if process["status"]: + flash(process["msg"]) return redirect(url_for("index")) + flash(process['msg'], "error") + return redirect(url_for("index")) -@app.route("/config/load", methods=["POST"]) + +@APP.route("/config/load", methods=["POST"]) def config_load(): + """ + Loads a config file from disk + """ file_name = request.form.get("name") if "load" in request.form: process = read_config(file_name) - if process["status"] == True: - flash(f"Loaded config from {file_name}!") + if process["status"]: flash(process["msg"]) return redirect(url_for("index")) - else: - flash(f"Failed to load {file_name}!", "error") - flash(process['msg'], "error") - return redirect(url_for("index")) + + flash(process['msg'], "error") + return redirect(url_for("index")) elif "delete" in request.form: - process = delete_file(cfg_dir + file_name) - if process["status"] == True: - flash(f"Deleted config {file_name}!") - return redirect(url_for("index")) - else: - flash(f"Failed to delete {file_name}!", "error") - flash(process['msg'], "error") + process = delete_file(CFG_DIR + file_name) + if process["status"]: + flash(process["msg"]) return redirect(url_for("index")) + flash(process['msg'], "error") + return redirect(url_for("index")) -@app.route("/logs/show", methods=["POST"]) + flash("Got an unhandled request (needs to be either load or delete)", "error") + return redirect(url_for("index")) + + +@APP.route("/logs/show", methods=["POST"]) def show_logs(): + """ + Displays system logs + """ lines = request.form.get("lines") or "200" scope = request.form.get("scope") or "default" @@ -291,60 +329,67 @@ def show_logs(): if process.returncode == 0: headers = {"content-type": "text/plain"} return process.stdout.decode("utf-8"), int(lines), headers - else: - flash("Failed to get logs") - flash(process.stdout.decode("utf-8"), "stdout") - flash(process.stderr.decode("utf-8"), "stderr") - return redirect(url_for("index")) + + flash("Failed to get logs") + flash(process.stdout.decode("utf-8"), "stdout") + flash(process.stderr.decode("utf-8"), "stderr") + return redirect(url_for("index")) -@app.route("/logs/level", methods=["POST"]) +@APP.route("/logs/level", methods=["POST"]) def log_level(): + """ + Sets RaSCSI backend log level + """ level = request.form.get("level") or "info" process = set_log_level(level) - if process["status"] == True: - flash(f"Log level set to {level}!") - return redirect(url_for("index")) - else: - flash(f"Failed to set log level to {level}!", "error") - flash(process["msg"], "error") + if process["status"]: + flash(f"Log level set to {level}") return redirect(url_for("index")) + flash(f"Failed to set log level to {level}!", "error") + return redirect(url_for("index")) -@app.route("/daynaport/attach", methods=["POST"]) + +@APP.route("/daynaport/attach", methods=["POST"]) def daynaport_attach(): + """ + Attaches a DaynaPORT ethernet adapter device + """ scsi_id = request.form.get("scsi_id") interface = request.form.get("if") - ip = request.form.get("ip") + ip_addr = request.form.get("ip") mask = request.form.get("mask") kwargs = {"device_type": "SCDP"} if interface != "": arg = interface - if "" not in (ip, mask): - arg += (":" + ip + "/" + mask) + if "" not in (ip_addr, mask): + arg += (":" + ip_addr + "/" + mask) kwargs["interfaces"] = arg process = attach_image(scsi_id, **kwargs) - if process["status"] == True: + if process["status"]: flash(f"Attached DaynaPORT to SCSI ID {scsi_id}!") return redirect(url_for("index")) - else: - flash(f"Failed to attach DaynaPORT to SCSI ID {scsi_id}!", "error") - flash(process["msg"], "error") - return redirect(url_for("index")) + + flash(process["msg"], "error") + return redirect(url_for("index")) -@app.route("/scsi/attach", methods=["POST"]) +@APP.route("/scsi/attach", methods=["POST"]) def attach(): + """ + Attaches a file image as a device + """ file_name = request.form.get("file_name") file_size = request.form.get("file_size") scsi_id = request.form.get("scsi_id") - un = request.form.get("un") + unit = request.form.get("unit") device_type = request.form.get("type") - kwargs = {"unit": int(un), "image": file_name} + kwargs = {"unit": int(unit), "image": file_name} # The most common block size is 512 bytes expected_block_size = 512 @@ -355,16 +400,13 @@ def attach(): expected_block_size = 2048 elif device_type == "SAHD": expected_block_size = 256 - + # Attempt to load the device properties file: - # same base path but with PROPERTIES_SUFFIX appended - from pathlib import Path - drive_properties = f"{cfg_dir}{file_name}.{PROPERTIES_SUFFIX}" + # same file name with PROPERTIES_SUFFIX appended + drive_properties = f"{CFG_DIR}{file_name}.{PROPERTIES_SUFFIX}" if Path(drive_properties).is_file(): process = read_drive_properties(drive_properties) - if process["status"] == False: - flash(f"Failed to load the device properties \ - file {file_name_base}.{PROPERTIES_SUFFIX}", "error") + if not process["status"]: flash(process["msg"], "error") return redirect(url_for("index")) conf = process["conf"] @@ -375,69 +417,80 @@ def attach(): expected_block_size = conf["block_size"] process = attach_image(scsi_id, **kwargs) - if process["status"] == True: - flash(f"Attached {file_name} to SCSI ID {scsi_id} LUN {un}!") + if process["status"]: + flash(f"Attached {file_name} to SCSI ID {scsi_id} LUN {unit}") if int(file_size) % int(expected_block_size): - flash(f"The image file size {file_size} bytes is not a multiple of \ - {expected_block_size} and RaSCSI will ignore the trailing data. \ - The image may be corrupted so proceed with caution.", "error") - return redirect(url_for("index")) - else: - flash(f"Failed to attach {file_name} to SCSI ID {scsi_id} LUN {un}!", "error") - flash(process["msg"], "error") + flash(f"The image file size {file_size} bytes is not a multiple of " + f"{expected_block_size} and RaSCSI will ignore the trailing data. " + f"The image may be corrupted so proceed with caution.", "error") return redirect(url_for("index")) + flash(f"Failed to attach {file_name} to SCSI ID {scsi_id} LUN {unit}", "error") + flash(process["msg"], "error") + return redirect(url_for("index")) -@app.route("/scsi/detach_all", methods=["POST"]) + +@APP.route("/scsi/detach_all", methods=["POST"]) def detach_all_devices(): + """ + Detaches all currently attached devices + """ process = detach_all() - if process["status"] == True: - flash("Detached all SCSI devices!") - return redirect(url_for("index")) - else: - flash("Failed to detach all SCSI devices!", "error") - flash(process["msg"], "error") + if process["status"]: + flash("Detached all SCSI devices") return redirect(url_for("index")) + flash(process["msg"], "error") + return redirect(url_for("index")) -@app.route("/scsi/detach", methods=["POST"]) + +@APP.route("/scsi/detach", methods=["POST"]) def detach(): + """ + Detaches a specified device + """ scsi_id = request.form.get("scsi_id") - un = request.form.get("un") - process = detach_by_id(scsi_id, un) - if process["status"] == True: - flash(f"Detached SCSI ID {scsi_id} LUN {un}!") - return redirect(url_for("index")) - else: - flash(f"Failed to detach SCSI ID {scsi_id} LUN {un}!", "error") - flash(process["msg"], "error") + unit = request.form.get("unit") + process = detach_by_id(scsi_id, unit) + if process["status"]: + flash(f"Detached SCSI ID {scsi_id} LUN {unit}") return redirect(url_for("index")) + flash(f"Failed to detach SCSI ID {scsi_id} LUN {unit}", "error") + flash(process["msg"], "error") + return redirect(url_for("index")) -@app.route("/scsi/eject", methods=["POST"]) + +@APP.route("/scsi/eject", methods=["POST"]) def eject(): + """ + Ejects a specified removable device image, but keeps the device attached + """ scsi_id = request.form.get("scsi_id") - un = request.form.get("un") + unit = request.form.get("unit") - process = eject_by_id(scsi_id, un) - if process["status"] == True: - flash(f"Ejected SCSI ID {scsi_id} LUN {un}!") - return redirect(url_for("index")) - else: - flash(f"Failed to eject SCSI ID {scsi_id} LUN {un}!", "error") - flash(process["msg"], "error") + process = eject_by_id(scsi_id, unit) + if process["status"]: + flash(f"Ejected SCSI ID {scsi_id} LUN {unit}") return redirect(url_for("index")) -@app.route("/scsi/info", methods=["POST"]) + flash(f"Failed to eject SCSI ID {scsi_id} LUN {unit}", "error") + flash(process["msg"], "error") + return redirect(url_for("index")) + +@APP.route("/scsi/info", methods=["POST"]) def device_info(): + """ + Displays detailed info for a specific device + """ scsi_id = request.form.get("scsi_id") - un = request.form.get("un") + unit = request.form.get("unit") - devices = list_devices(scsi_id, un) + devices = list_devices(scsi_id, unit) # First check if any device at all was returned - if devices["status"] == False: - flash(f"No device attached to SCSI ID {scsi_id} LUN {un}!", "error") + if not devices["status"]: + flash(devices["msg"], "error") return redirect(url_for("index")) # Looking at the first dict in list to get # the one and only device that should have been returned @@ -456,12 +509,15 @@ def device_info(): flash(f"Block Size: {device['block_size']} bytes") flash(f"Image Size: {device['size']} bytes") return redirect(url_for("index")) - else: - flash(f"Failed to get device info for SCSI ID {scsi_id} LUN {un}!", "error") - return redirect(url_for("index")) -@app.route("/pi/reboot", methods=["POST"]) + flash(devices["msg"], "error") + return redirect(url_for("index")) + +@APP.route("/pi/reboot", methods=["POST"]) def restart(): + """ + Restarts the Pi + """ detach_all() flash("Safely detached all devices.") flash("Rebooting the Pi momentarily...") @@ -469,8 +525,11 @@ def restart(): return redirect(url_for("index")) -@app.route("/rascsi/restart", methods=["POST"]) +@APP.route("/rascsi/restart", methods=["POST"]) def rascsi_restart(): + """ + Restarts the RaSCSI backend service + """ detach_all() flash("Safely detached all devices.") flash("Restarting RaSCSI Service...") @@ -479,8 +538,11 @@ def rascsi_restart(): return redirect(url_for("index")) -@app.route("/pi/shutdown", methods=["POST"]) +@APP.route("/pi/shutdown", methods=["POST"]) def shutdown(): + """ + Shuts down the Pi + """ detach_all() flash("Safely detached all devices.") flash("Shutting down the Pi momentarily...") @@ -488,62 +550,73 @@ def shutdown(): return redirect(url_for("index")) -@app.route("/files/download_to_iso", methods=["POST"]) +@APP.route("/files/download_to_iso", methods=["POST"]) def download_to_iso(): + """ + Downloads a remote file and creates a CD-ROM image formatted with HFS that contains the file + """ scsi_id = request.form.get("scsi_id") - url = request.form.get("url") - process = download_file_to_iso(scsi_id, url) - if process["status"] == True: - flash(f"Created CD-ROM image {process['file_name']}") - flash(process["msg"]) + + process = download_file_to_iso(url) + if process["status"]: + flash(f"Created CD-ROM image: {process['file_name']}") else: flash(f"Failed to create CD-ROM image from {url}", "error") flash(process["msg"], "error") return redirect(url_for("index")) process_attach = attach_image(scsi_id, device_type="SCCD", image=process["file_name"]) - if process_attach["status"] == True: + if process_attach["status"]: flash(f"Attached to SCSI ID {scsi_id}") return redirect(url_for("index")) - else: - flash(f"Failed to attach to SCSI ID {scsi_id}. Try attaching it manually.", "error") - flash(process_attach["msg"], "error") - return redirect(url_for("index")) + + flash(f"Failed to attach image to SCSI ID {scsi_id}. Try attaching it manually.", "error") + flash(process_attach["msg"], "error") + return redirect(url_for("index")) -@app.route("/files/download_to_images", methods=["POST"]) +@APP.route("/files/download_to_images", methods=["POST"]) def download_img(): + """ + Downloads a remote file onto the images dir on the Pi + """ url = request.form.get("url") server_info = get_server_info() process = download_to_dir(url, server_info["image_dir"]) - if process["status"] == True: - flash(f"File Downloaded from {url} to {server_info['image_dir']}") - return redirect(url_for("index")) - else: - flash(f"Failed to download file {url}", "error") - flash(process["msg"], "error") + if process["status"]: + flash(process["msg"]) return redirect(url_for("index")) + flash(f"Failed to download file {url}", "error") + flash(process["msg"], "error") + return redirect(url_for("index")) -@app.route("/files/download_to_afp", methods=["POST"]) + +@APP.route("/files/download_to_afp", methods=["POST"]) def download_afp(): + """ + Downloads a remote file onto the AFP shared dir on the Pi + """ url = request.form.get("url") - process = download_to_dir(url, afp_dir) - if process["status"] == True: - flash(f"File Downloaded from {url} to {afp_dir}") - return redirect(url_for("index")) - else: - flash(f"Failed to download file {url}", "error") - flash(process["msg"], "error") + process = download_to_dir(url, AFP_DIR) + if process["status"]: + flash(process["msg"]) return redirect(url_for("index")) + flash(f"Failed to download file {url}", "error") + flash(process["msg"], "error") + return redirect(url_for("index")) -@app.route("/files/upload", methods=["POST"]) + +@APP.route("/files/upload", methods=["POST"]) def upload_file(): + """ + Uploads a file from the local computer to the images dir on the Pi + Depending on the Dropzone.js JavaScript library + """ from werkzeug.utils import secure_filename from os import path - import pydrop log = logging.getLogger("pydrop") file = request.files["file"] @@ -554,15 +627,15 @@ def upload_file(): save_path = path.join(server_info["image_dir"], filename) current_chunk = int(request.form['dzchunkindex']) - # Makes sure not to overwrite an existing file, - # but continues writing to a file transfer in progress + # Makes sure not to overwrite an existing file, + # but continues writing to a file transfer in progress if path.exists(save_path) and current_chunk == 0: return make_response((f"The file {file.filename} already exists!", 400)) try: - with open(save_path, "ab") as f: - f.seek(int(request.form["dzchunkbyteoffset"])) - f.write(file.stream.read()) + with open(save_path, "ab") as save: + save.seek(int(request.form["dzchunkbyteoffset"])) + save.write(file.stream.read()) except OSError: log.exception("Could not write to file") return make_response(("Unable to write the file to disk!", 500)) @@ -572,22 +645,24 @@ def upload_file(): if current_chunk + 1 == total_chunks: # Validate the resulting file size after writing the last chunk if path.getsize(save_path) != int(request.form["dztotalfilesize"]): - log.error(f"Finished transferring {file.filename}, " - f"but it has a size mismatch with the original file." - f"Got {path.getsize(save_path)} but we " - f"expected {request.form['dztotalfilesize']}.") + log.error("Finished transferring %s, " + "but it has a size mismatch with the original file." + "Got %s but we expected %s.", + file.filename, path.getsize(save_path), request.form['dztotalfilesize']) return make_response(("Transferred file corrupted!", 500)) - else: - log.info(f"File {file.filename} has been uploaded successfully") - else: - log.debug(f"Chunk {current_chunk + 1} of {total_chunks} " - f"for file {file.filename} completed.") + + log.info("File %s has been uploaded successfully", file.filename) + log.debug("Chunk %s of %s for file %s completed.", + current_chunk + 1, total_chunks, file.filename) return make_response(("File upload successful!", 200)) -@app.route("/files/create", methods=["POST"]) +@APP.route("/files/create", methods=["POST"]) def create_file(): + """ + Creates an empty image file in the images dir + """ file_name = request.form.get("file_name") size = (int(request.form.get("size")) * 1024 * 1024) file_type = request.form.get("type") @@ -596,90 +671,84 @@ def create_file(): file_name = secure_filename(file_name) process = create_new_image(file_name, file_type, size) - if process["status"] == True: - flash(f"Drive image created as {file_name}.{file_type}") - flash(process["msg"]) - return redirect(url_for("index")) - else: - flash(f"Failed to create file {file_name}.{file_type}", "error") - flash(process["msg"], "error") + if process["status"]: + flash(f"Drive image created: {file_name}.{file_type}") return redirect(url_for("index")) + flash(process["msg"], "error") + return redirect(url_for("index")) -@app.route("/files/download", methods=["POST"]) + +@APP.route("/files/download", methods=["POST"]) def download(): + """ + Downloads a file from the Pi to the local computer + """ image = request.form.get("image") server_info = get_server_info() return send_file(f"{server_info['image_dir']}/{image}", as_attachment=True) -@app.route("/files/delete", methods=["POST"]) +@APP.route("/files/delete", methods=["POST"]) def delete(): + """ + Deletes a specified file in the images dir + """ file_name = request.form.get("image") process = delete_image(file_name) - if process["status"] == True: - flash(f"File {file_name} deleted!") - flash(process["msg"]) + if process["status"]: + flash(f"Image file deleted: {file_name}") else: - flash(f"Failed to delete file {file_name}!", "error") flash(process["msg"], "error") return redirect(url_for("index")) # Delete the drive properties file, if it exists - from pathlib import Path - prop_file_path = f"{cfg_dir}{file_name}.{PROPERTIES_SUFFIX}" + prop_file_path = f"{CFG_DIR}{file_name}.{PROPERTIES_SUFFIX}" if Path(prop_file_path).is_file(): process = delete_file(prop_file_path) - if process["status"] == True: - flash(f"File {prop_file_path} deleted!") - return redirect(url_for("index")) - else: - flash(f"Failed to delete file {prop_file_path}!", "error") - flash(process["msg"], "error") + if process["status"]: + flash(process["msg"]) return redirect(url_for("index")) + flash(process["msg"], "error") + return redirect(url_for("index")) + return redirect(url_for("index")) -@app.route("/files/unzip", methods=["POST"]) +@APP.route("/files/unzip", methods=["POST"]) def unzip(): + """ + Unzips a specified zip file + """ image = request.form.get("image") member = request.form.get("member") or False process = unzip_file(image, member) if process["status"]: - if len(process["msg"]) < 1: + if not process["msg"]: flash("Aborted unzip: File(s) with the same name already exists.", "error") return redirect(url_for("index")) flash("Unzipped the following files:") - for m in process["msg"]: - flash(m) - return redirect(url_for("index")) - else: - flash("Failed to unzip " + image, "error") - flash(process["msg"], "error") + for msg in process["msg"]: + flash(msg) return redirect(url_for("index")) + flash("Failed to unzip " + image, "error") + flash(process["msg"], "error") + return redirect(url_for("index")) + if __name__ == "__main__": - app.secret_key = "rascsi_is_awesome_insecure_secret_key" - app.config["SESSION_TYPE"] = "filesystem" - - server_info = get_server_info() - app.config["UPLOAD_FOLDER"] = server_info["image_dir"] - - from os import makedirs - makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) - app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE + APP.secret_key = "rascsi_is_awesome_insecure_secret_key" + APP.config["SESSION_TYPE"] = "filesystem" + APP.config["MAX_CONTENT_LENGTH"] = int(MAX_FILE_SIZE) # Load the default configuration file, if found - from pathlib import Path - default_config_path = Path(cfg_dir + DEFAULT_CONFIG) - if default_config_path.is_file(): + if Path(DEFAULT_CONFIG).is_file(): read_config(DEFAULT_CONFIG) import bjoern print("Serving rascsi-web...") - bjoern.run(app, "0.0.0.0", 8080) - + bjoern.run(APP, "0.0.0.0", 8080)