Merge pull request #846 from nucleogenic/webui-json-responses

JSON API and test suite for web UI
This commit is contained in:
nucleogenic 2022-09-26 00:18:06 +01:00 committed by GitHub
commit ed1285327a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1373 additions and 276 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ src/raspberrypi/hfdisk/
*~ *~
messages.pot messages.pot
messages.mo messages.mo
report.xml
docker/docker-compose.override.yml docker/docker-compose.override.yml
/docker/volumes/images/* /docker/volumes/images/*

View File

@ -6,18 +6,29 @@ FROM "${OS_ARCH}/${OS_DISTRO}:${OS_VERSION}"
EXPOSE 80 443 EXPOSE 80 443
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends sudo rsyslog procps RUN apt-get update && apt-get install -y --no-install-recommends sudo systemd rsyslog procps
RUN groupadd pi RUN groupadd pi
RUN useradd --create-home --shell /bin/bash -g pi pi RUN useradd --create-home --shell /bin/bash -g pi pi
RUN echo "pi ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers RUN echo "pi ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
RUN echo "pi:rascsi" | chpasswd
WORKDIR /home/pi RUN mkdir /home/pi/afpshare
RUN touch /etc/dhcpcd.conf
RUN mkdir -p /etc/network/interfaces.d/
WORKDIR /home/pi/RASCSI
USER pi USER pi
COPY --chown=pi:pi . RASCSI COPY --chown=pi:pi . .
RUN cd RASCSI && ./easyinstall.sh --run_choice=11 --skip-token
# Standalone RaSCSI web UI
RUN ./easyinstall.sh --run_choice=11 --skip-token
# Wired network bridge
RUN ./easyinstall.sh --run_choice=6 --headless
USER root USER root
WORKDIR /home/pi
RUN pip3 install watchdog RUN pip3 install watchdog
COPY docker/rascsi-web/start.sh /usr/local/bin/start.sh COPY docker/rascsi-web/start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh

View File

@ -6,15 +6,15 @@ FROM "${OS_ARCH}/${OS_DISTRO}:${OS_VERSION}"
EXPOSE 6868 EXPOSE 6868
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends sudo rsyslog patch RUN apt-get update && apt-get install -y --no-install-recommends sudo systemd rsyslog patch
RUN groupadd pi RUN groupadd pi
RUN useradd --create-home --shell /bin/bash -g pi pi RUN useradd --create-home --shell /bin/bash -g pi pi
RUN echo "pi ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers RUN echo "pi ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
USER pi
COPY --chown=pi:pi . /home/pi/RASCSI
WORKDIR /home/pi/RASCSI WORKDIR /home/pi/RASCSI
USER pi
COPY --chown=pi:pi . .
# Workaround for Bullseye amd64 compilation error # Workaround for Bullseye amd64 compilation error
# https://github.com/akuker/RASCSI/issues/821 # https://github.com/akuker/RASCSI/issues/821
@ -24,6 +24,7 @@ RUN patch -p0 < docker/rascsi/cfilesystem.patch
RUN ./easyinstall.sh --run_choice=10 --cores=`nproc` --skip-token RUN ./easyinstall.sh --run_choice=10 --cores=`nproc` --skip-token
USER root USER root
WORKDIR /home/pi
COPY docker/rascsi/rascsi_wrapper.sh /usr/local/bin/rascsi_wrapper.sh COPY docker/rascsi/rascsi_wrapper.sh /usr/local/bin/rascsi_wrapper.sh
RUN chmod +x /usr/local/bin/rascsi_wrapper.sh RUN chmod +x /usr/local/bin/rascsi_wrapper.sh
CMD ["/usr/local/bin/rascsi_wrapper.sh", "-L", "trace", "-r", "7", "-F", "/home/pi/images"] CMD ["/usr/local/bin/rascsi_wrapper.sh", "-L", "trace", "-r", "7", "-F", "/home/pi/images"]

View File

@ -691,6 +691,8 @@ function setupWiredNetworking() {
echo "WARNING: If you continue, the IP address of your Pi may change upon reboot." echo "WARNING: If you continue, the IP address of your Pi may change upon reboot."
echo "Please make sure you will not lose access to the Pi system." echo "Please make sure you will not lose access to the Pi system."
echo "" echo ""
if [[ -z $HEADLESS ]]; then
echo "Do you want to proceed with network configuration using the default settings? [Y/n]" echo "Do you want to proceed with network configuration using the default settings? [Y/n]"
read REPLY read REPLY
@ -701,13 +703,19 @@ function setupWiredNetworking() {
read SELECTED read SELECTED
LAN_INTERFACE=$SELECTED LAN_INTERFACE=$SELECTED
fi fi
fi
if [ "$(grep -c "^denyinterfaces" /etc/dhcpcd.conf)" -ge 1 ]; then if [ "$(grep -c "^denyinterfaces" /etc/dhcpcd.conf)" -ge 1 ]; then
echo "WARNING: Network forwarding may already have been configured. Proceeding will overwrite the configuration." echo "WARNING: Network forwarding may already have been configured. Proceeding will overwrite the configuration."
if [[ -z $HEADLESS ]]; then
echo "Press enter to continue or CTRL-C to exit" echo "Press enter to continue or CTRL-C to exit"
read REPLY read REPLY
fi
sudo sed -i /^denyinterfaces/d /etc/dhcpcd.conf sudo sed -i /^denyinterfaces/d /etc/dhcpcd.conf
fi fi
sudo bash -c 'echo "denyinterfaces '$LAN_INTERFACE'" >> /etc/dhcpcd.conf' sudo bash -c 'echo "denyinterfaces '$LAN_INTERFACE'" >> /etc/dhcpcd.conf'
echo "Modified /etc/dhcpcd.conf" echo "Modified /etc/dhcpcd.conf"
@ -720,6 +728,12 @@ function setupWiredNetworking() {
echo "Either use the Web UI, or do this on the command line (assuming SCSI ID 6):" echo "Either use the Web UI, or do this on the command line (assuming SCSI ID 6):"
echo "rasctl -i 6 -c attach -t scdp -f $LAN_INTERFACE" echo "rasctl -i 6 -c attach -t scdp -f $LAN_INTERFACE"
echo "" echo ""
if [[ $HEADLESS ]]; then
echo "Skipping reboot in headless mode"
return 0
fi
echo "We need to reboot your Pi" echo "We need to reboot your Pi"
echo "Press Enter to reboot or CTRL-C to exit" echo "Press Enter to reboot or CTRL-C to exit"
read read
@ -1269,6 +1283,7 @@ function runChoice() {
preparePythonCommon preparePythonCommon
cachePipPackages cachePipPackages
installRaScsiWebInterface installRaScsiWebInterface
enableWebInterfaceAuth
echo "Configuring RaSCSI Web Interface stand-alone - Complete!" echo "Configuring RaSCSI Web Interface stand-alone - Complete!"
echo "Launch the Web Interface with the 'start.sh' script. To use a custom port for the web server: 'start.sh --web-port=8081" echo "Launch the Web Interface with the 'start.sh' script. To use a custom port for the web server: 'start.sh --web-port=8081"
;; ;;
@ -1367,6 +1382,9 @@ while [ "$1" != "" ]; do
-s | --skip-token) -s | --skip-token)
SKIP_TOKEN=1 SKIP_TOKEN=1
;; ;;
-h | --headless)
HEADLESS=1
;;
*) *)
echo "ERROR: Unknown parameter \"$PARAM\"" echo "ERROR: Unknown parameter \"$PARAM\""
exit 1 exit 1

View File

@ -521,6 +521,8 @@ class FileCmds:
# introduce more sophisticated format detection logic here. # introduce more sophisticated format detection logic here.
if isinstance(config, dict): if isinstance(config, dict):
self.ractl.detach_all() self.ractl.detach_all()
for scsi_id in range(0, 8):
RESERVATIONS[scsi_id] = ""
ids_to_reserve = [] ids_to_reserve = []
for item in config["reserved_ids"]: for item in config["reserved_ids"]:
ids_to_reserve.append(item["id"]) ids_to_reserve.append(item["id"])

View File

@ -40,7 +40,7 @@ class RaCtlCmds:
version = (str(result.server_info.version_info.major_version) + "." + version = (str(result.server_info.version_info.major_version) + "." +
str(result.server_info.version_info.minor_version) + "." + str(result.server_info.version_info.minor_version) + "." +
str(result.server_info.version_info.patch_version)) str(result.server_info.version_info.patch_version))
log_levels = result.server_info.log_level_info.log_levels log_levels = list(result.server_info.log_level_info.log_levels)
current_log_level = result.server_info.log_level_info.current_log_level current_log_level = result.server_info.log_level_info.current_log_level
reserved_ids = list(result.server_info.reserved_ids_info.ids) reserved_ids = list(result.server_info.reserved_ids_info.ids)
image_dir = result.server_info.image_files_info.default_image_folder image_dir = result.server_info.image_files_info.default_image_folder
@ -113,7 +113,7 @@ class RaCtlCmds:
result = proto.PbResult() result = proto.PbResult()
result.ParseFromString(data) result.ParseFromString(data)
ifs = result.network_interfaces_info.name ifs = result.network_interfaces_info.name
return {"status": result.status, "ifs": ifs} return {"status": result.status, "ifs": list(ifs)}
def get_device_types(self): def get_device_types(self):
""" """
@ -140,7 +140,7 @@ class RaCtlCmds:
"removable": device.properties.removable, "removable": device.properties.removable,
"supports_file": device.properties.supports_file, "supports_file": device.properties.supports_file,
"params": params, "params": params,
"block_sizes": device.properties.block_sizes, "block_sizes": list(device.properties.block_sizes),
} }
return {"status": result.status, "device_types": device_types} return {"status": result.status, "device_types": device_types}
@ -394,7 +394,7 @@ class RaCtlCmds:
dpath = result.devices_info.devices[i].file.name dpath = result.devices_info.devices[i].file.name
dfile = dpath.replace(image_files_info["images_dir"] + "/", "") dfile = dpath.replace(image_files_info["images_dir"] + "/", "")
dparam = result.devices_info.devices[i].params dparam = dict(result.devices_info.devices[i].params)
dven = result.devices_info.devices[i].vendor dven = result.devices_info.devices[i].vendor
dprod = result.devices_info.devices[i].product dprod = result.devices_info.devices[i].product
drev = result.devices_info.devices[i].revision drev = result.devices_info.devices[i].revision

2
python/web/.flake8 Normal file
View File

@ -0,0 +1,2 @@
[flake8]
max-line-length = 100

View File

@ -0,0 +1,8 @@
[tool.pytest.ini_options]
addopts = "--junitxml=report.xml"
log_cli = true
log_cli_level = "warn"
[tool.black]
line-length = 100
target-version = ['py37', 'py38', 'py39']

View File

@ -0,0 +1,4 @@
pytest==7.1.3
pytest-httpserver==1.0.6
black==22.8.0
flake8==5.0.4

View File

@ -31,18 +31,33 @@ table, tr, td {
margin: none; margin: none;
} }
.error { div.flash {
color: white; margin-top: 5px;
font-size:20px; margin-bottom: 5px;
background-color:red;
white-space: pre-line;
} }
.message { div.flash div {
color: white; color: white;
font-size:20px; font-size: 18px;
background-color:green;
white-space: pre-line; white-space: pre-line;
padding: 2px 5px;
}
div.flash div.success {
background-color: green;
}
div.flash div.warning {
background-color: orange;
color: black;
}
div.flash div.error {
background-color: red;
}
div.flash div.info {
background-color: #0d6efd;
} }
td.inactive { td.inactive {

View File

@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<title>{{ _("RaSCSI Reloaded Control Page") }} [{{ host }}]</title> <title>{{ _("RaSCSI Reloaded Control Page") }} [{{ env["host"] }}]</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link rel="apple-touch-icon" sizes="57x57" href="/pwa/apple-icon-57x57.png"> <link rel="apple-touch-icon" sizes="57x57" href="/pwa/apple-icon-57x57.png">
@ -26,12 +26,12 @@
<script type="application/javascript"> <script type="application/javascript">
var processNotify = function(Notification) { var processNotify = function(Notification) {
document.getElementById("flash").innerHTML = "<div class='message'>" + Notification + "{{ _(" This process may take a while, and will continue in the background if you navigate away from this page.") }}</div>"; document.getElementById("flash").innerHTML = "<div class=\"info\">" + Notification + "{{ _(" This process may take a while, and will continue in the background if you navigate away from this page.") }}</div>";
window.scrollTo(0,0); window.scrollTo(0,0);
} }
var shutdownNotify = function(Notification) { var shutdownNotify = function(Notification) {
document.getElementById("flash").innerHTML = "<div class='message'>" + Notification + "{{ _(" The Web Interface will become unresponsive momentarily. Reload this page after the Pi has started up again.") }}</div>"; document.getElementById("flash").innerHTML = "<div class=\"info\">" + Notification + "{{ _(" The Web Interface will become unresponsive momentarily. Reload this page after the Pi has started up again.") }}</div>";
window.scrollTo(0,0); window.scrollTo(0,0);
} }
</script> </script>
@ -43,9 +43,9 @@
<body> <body>
<div class="content"> <div class="content">
<div class="header"> <div class="header">
{% if auth_active %} {% if env["auth_active"] %}
{% if username %} {% if env["username"] %}
<span style="display: inline-block; width: 100%; color: white; background-color: green; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;">{{ _("Logged in as <em>%(username)s</em>", username=username) }} &#8211; <a href="/logout">{{ _("Log Out") }}</a></span> <span style="display: inline-block; width: 100%; color: white; background-color: green; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;">{{ _("Logged in as <em>%(username)s</em>", username=env["username"]) }} &#8211; <a href="/logout">{{ _("Log Out") }}</a></span>
{% else %} {% else %}
<span style="display: inline-block; width: 100%; color: white; background-color: red; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;"> <span style="display: inline-block; width: 100%; color: white; background-color: red; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;">
<form method="POST" action="/login"> <form method="POST" action="/login">
@ -70,7 +70,7 @@
</tr> </tr>
<tr> <tr>
<td style="color: white;"> <td style="color: white;">
hostname: {{ host }} ip: {{ ip_addr }} hostname: {{ env["host"] }} ip: {{ env["ip_addr"] }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -89,8 +89,8 @@
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
</div> </div>
<div class="footer"> <div class="footer">
<center><tt>{{ _("RaSCSI Reloaded version: ") }}<strong>{{ version }} <a href="https://github.com/akuker/RASCSI/commit/{{ running_env['git'] }}" target="_blank">{{ running_env["git"][:7] }}</a></strong></tt></center> <center><tt>{{ _("RaSCSI Reloaded version: ") }}<strong>{{ version }} <a href="https://github.com/akuker/RASCSI/commit/{{ env["running_env"]["git"] }}" target="_blank">{{ env["running_env"]["git"][:7] }}</a></strong></tt></center>
<center><tt>{{ _("Pi environment: ") }}{{ running_env["env"] }}</tt></center> <center><tt>{{ _("Pi environment: ") }}{{ env["running_env"]["env"] }}</tt></center>
</div> </div>
</div> </div>
</body> </body>

View File

@ -135,7 +135,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<p><small>{{ _("%(disk_space)s MiB disk space remaining on the Pi", disk_space=free_disk) }}</small></p> <p><small>{{ _("%(disk_space)s MiB disk space remaining on the Pi", disk_space=env["free_disk_space"]) }}</small></p>
<p><a href="/">{{ _("Cancel") }}</a></p> <p><a href="/">{{ _("Cancel") }}</a></p>
{% endblock content %} {% endblock content %}

View File

@ -156,8 +156,16 @@
<li>{{ _("Select a valid SCSI ID and <a href=\"%(url)s\">LUN</a> to attach to. Unless you know what you're doing, always use LUN 0.", url="https://en.wikipedia.org/wiki/Logical_unit_number") }} <li>{{ _("Select a valid SCSI ID and <a href=\"%(url)s\">LUN</a> to attach to. Unless you know what you're doing, always use LUN 0.", url="https://en.wikipedia.org/wiki/Logical_unit_number") }}
</li> </li>
<li>{{ _("If RaSCSI was unable to detect the media type associated with the image, you get to choose the type from the dropdown.") }}</li> <li>{{ _("If RaSCSI was unable to detect the media type associated with the image, you get to choose the type from the dropdown.") }}</li>
<li>{{ _("Recognized image file types: %(valid_image_suffixes)s", valid_image_suffixes=valid_image_suffixes) }}</li> <li>
<li>{{ _("Recognized archive file types: %(ARCHIVE_FILE_SUFFIXES)s", ARCHIVE_FILE_SUFFIXES=ARCHIVE_FILE_SUFFIXES) }}</li> {{ _("Recognized image file types:") }}
{% set comma = joiner(", ") %}
{% for extension in valid_image_suffixes %}{{ comma() }}.{{ extension}}{% endfor %}
</li>
<li>
{{ _("Recognized archive file types:") }}
{% set comma = joiner(", ") %}
{% for extension in ARCHIVE_FILE_SUFFIXES %}{{ comma() }}.{{ extension}}{% endfor %}
</li>
</ul> </ul>
</details> </details>
@ -301,7 +309,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<p><small>{{ _("%(disk_space)s MiB disk space remaining on the Pi", disk_space=free_disk) }}</small></p> <p><small>{{ _("%(disk_space)s MiB disk space remaining on the Pi", disk_space=env["free_disk_space"]) }}</small></p>
<hr/> <hr/>
<details> <details>

View File

@ -27,6 +27,7 @@ from flask import (
make_response, make_response,
session, session,
abort, abort,
jsonify,
) )
from rascsi.ractl_cmds import RaCtlCmds from rascsi.ractl_cmds import RaCtlCmds
@ -67,6 +68,66 @@ from settings import (
APP = Flask(__name__) APP = Flask(__name__)
BABEL = Babel(APP) BABEL = Babel(APP)
def get_env_info():
"""
Get information about the app/host environment
"""
ip_addr, host = sys_cmd.get_ip_and_host()
if "username" in session:
username = session["username"]
else:
username = None
return {
"running_env": sys_cmd.running_env(),
"username": username,
"auth_active": auth_active(AUTH_GROUP)["status"],
"ip_addr": ip_addr,
"host": host,
"free_disk_space": int(sys_cmd.disk_space()["free"] / 1024 / 1024),
}
def response(
template=None,
message=None,
redirect_url=None,
error=False,
status_code=200,
**kwargs
):
"""
Generates a HTML or JSON HTTP response
"""
status = "error" if error else "success"
if isinstance(message, list):
messages = message
elif message is None:
messages = []
else:
messages = [(str(message), status)]
if request.headers.get("accept") == "application/json":
return jsonify({
"status": status,
"messages": [{"message": m, "category": c} for m, c in messages],
"data": kwargs
}), status_code
if messages:
for message, category in messages:
flash(message, category)
if template:
kwargs["env"] = get_env_info()
return render_template(template, **kwargs)
if redirect_url:
return redirect(url_for(redirect_url))
return redirect(url_for("index"))
@BABEL.localeselector @BABEL.localeselector
def get_locale(): def get_locale():
@ -87,12 +148,14 @@ def get_locale():
def get_supported_locales(): def get_supported_locales():
""" """
Returns a list of Locale objects that the Web Interfaces supports Returns a list of languages supported by the web UI
""" """
locales = BABEL.list_translations() locales = [
locales.append(Locale("en")) {"language": x.language, "display_name": x.display_name}
sorted_locales = sorted(locales, key=lambda x: x.language) for x in [*BABEL.list_translations(), Locale("en")]
return sorted_locales ]
return sorted(locales, key=lambda x: x["language"])
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
@ -147,7 +210,7 @@ def index():
server_info["scmo"] server_info["scmo"]
) )
valid_image_suffixes = "." + ", .".join( valid_image_suffixes = (
server_info["schd"] + server_info["schd"] +
server_info["scrm"] + server_info["scrm"] +
server_info["scmo"] + server_info["scmo"] +
@ -159,14 +222,12 @@ def index():
else: else:
username = None username = None
return render_template( return response(
"index.html", template="index.html",
locales=get_supported_locales(), locales=get_supported_locales(),
bridge_configured=sys_cmd.is_bridge_setup(), bridge_configured=sys_cmd.is_bridge_setup(),
netatalk_configured=sys_cmd.running_proc("afpd"), netatalk_configured=sys_cmd.running_proc("afpd"),
macproxy_configured=sys_cmd.running_proc("macproxy"), macproxy_configured=sys_cmd.running_proc("macproxy"),
ip_addr=ip_addr,
host=host,
devices=formatted_devices, devices=formatted_devices,
files=extended_image_files, files=extended_image_files,
config_files=config_files, config_files=config_files,
@ -181,28 +242,32 @@ def index():
reserved_scsi_ids=reserved_scsi_ids, reserved_scsi_ids=reserved_scsi_ids,
RESERVATIONS=RESERVATIONS, RESERVATIONS=RESERVATIONS,
max_file_size=int(int(MAX_FILE_SIZE) / 1024 / 1024), max_file_size=int(int(MAX_FILE_SIZE) / 1024 / 1024),
running_env=sys_cmd.running_env(),
version=server_info["version"], version=server_info["version"],
log_levels=server_info["log_levels"], log_levels=server_info["log_levels"],
current_log_level=server_info["current_log_level"], current_log_level=server_info["current_log_level"],
netinfo=ractl_cmd.get_network_info(), netinfo=ractl_cmd.get_network_info(),
device_types=device_types, device_types=device_types,
free_disk=int(sys_cmd.disk_space()["free"] / 1024 / 1024),
image_suffixes_to_create=image_suffixes_to_create, image_suffixes_to_create=image_suffixes_to_create,
valid_image_suffixes=valid_image_suffixes, valid_image_suffixes=valid_image_suffixes,
cdrom_file_suffix=tuple(server_info["sccd"]), cdrom_file_suffix=tuple(server_info["sccd"]),
removable_file_suffix=tuple(server_info["scrm"]), removable_file_suffix=tuple(server_info["scrm"]),
mo_file_suffix=tuple(server_info["scmo"]), mo_file_suffix=tuple(server_info["scmo"]),
username=username,
auth_active=auth_active(AUTH_GROUP)["status"],
PROPERTIES_SUFFIX=PROPERTIES_SUFFIX, PROPERTIES_SUFFIX=PROPERTIES_SUFFIX,
ARCHIVE_FILE_SUFFIXES="." + ", .".join(ARCHIVE_FILE_SUFFIXES), ARCHIVE_FILE_SUFFIXES=ARCHIVE_FILE_SUFFIXES,
REMOVABLE_DEVICE_TYPES=ractl_cmd.get_removable_device_types(), REMOVABLE_DEVICE_TYPES=ractl_cmd.get_removable_device_types(),
DISK_DEVICE_TYPES=ractl_cmd.get_disk_device_types(), DISK_DEVICE_TYPES=ractl_cmd.get_disk_device_types(),
PERIPHERAL_DEVICE_TYPES=ractl_cmd.get_peripheral_device_types(), PERIPHERAL_DEVICE_TYPES=ractl_cmd.get_peripheral_device_types(),
) )
@APP.route("/env")
def env():
"""
Shows information about the app/host environment
"""
return response(**get_env_info())
@APP.route("/drive/list", methods=["GET"]) @APP.route("/drive/list", methods=["GET"])
def drive_list(): def drive_list():
""" """
@ -211,23 +276,20 @@ def drive_list():
# Reads the canonical drive properties into a dict # Reads the canonical drive properties into a dict
# The file resides in the current dir of the web ui process # The file resides in the current dir of the web ui process
drive_properties = Path(DRIVE_PROPERTIES_FILE) drive_properties = Path(DRIVE_PROPERTIES_FILE)
if drive_properties.is_file(): if not drive_properties.is_file():
return response(
error=True,
message=_("Could not read drive properties from %(properties_file)s",
properties_file=drive_properties),
)
process = file_cmd.read_drive_properties(str(drive_properties)) process = file_cmd.read_drive_properties(str(drive_properties))
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if not process["status"]:
flash(process["msg"], "error")
return redirect(url_for("index"))
conf = process["conf"]
else:
flash(
_(
"Could not read drive properties from %(properties_file)s",
properties_file=drive_properties,
),
"error",
)
return redirect(url_for("index"))
if not process["status"]:
return response(error=True, message=process["msg"])
conf = process["conf"]
hd_conf = [] hd_conf = []
cd_conf = [] cd_conf = []
rm_conf = [] rm_conf = []
@ -245,29 +307,17 @@ def drive_list():
device["size_mb"] = "{:,.2f}".format(device["size"] / 1024 / 1024) device["size_mb"] = "{:,.2f}".format(device["size"] / 1024 / 1024)
rm_conf.append(device) rm_conf.append(device)
if "username" in session:
username = session["username"]
else:
username = None
server_info = ractl_cmd.get_server_info() server_info = ractl_cmd.get_server_info()
ip_addr, host = sys_cmd.get_ip_and_host()
return render_template( return response(
"drives.html", "drives.html",
files=file_cmd.list_images()["files"], files=file_cmd.list_images()["files"],
base_dir=server_info["image_dir"], base_dir=server_info["image_dir"],
hd_conf=hd_conf, hd_conf=hd_conf,
cd_conf=cd_conf, cd_conf=cd_conf,
rm_conf=rm_conf, rm_conf=rm_conf,
running_env=sys_cmd.running_env(),
version=server_info["version"], version=server_info["version"],
free_disk=int(sys_cmd.disk_space()["free"] / 1024 / 1024),
cdrom_file_suffix=tuple(server_info["sccd"]), cdrom_file_suffix=tuple(server_info["sccd"]),
username=username,
auth_active=auth_active(AUTH_GROUP)["status"],
ip_addr=ip_addr,
host=host,
) )
@ -278,20 +328,17 @@ def login():
""" """
username = request.form["username"] username = request.form["username"]
password = request.form["password"] password = request.form["password"]
groups = [g.gr_name for g in getgrall() if username in g.gr_mem] groups = [g.gr_name for g in getgrall() if username in g.gr_mem]
if AUTH_GROUP in groups: if AUTH_GROUP in groups:
if authenticate(str(username), str(password)): if authenticate(str(username), str(password)):
session["username"] = request.form["username"] session["username"] = request.form["username"]
return redirect(url_for("index")) return response(env=get_env_info())
flash(
_( return response(error=True, status_code=401, message=_(
"You must log in with credentials for a user in the '%(group)s' group", "You must log in with valid credentials for a user in the '%(group)s' group",
group=AUTH_GROUP, group=AUTH_GROUP,
), ))
"error",
)
return redirect(url_for("index"))
@APP.route("/logout") @APP.route("/logout")
@ -300,7 +347,7 @@ def logout():
Removes the logged in user from the session Removes the logged in user from the session
""" """
session.pop("username", None) session.pop("username", None)
return redirect(url_for("index")) return response()
@APP.route("/pwa/<path:pwa_path>") @APP.route("/pwa/<path:pwa_path>")
@ -319,8 +366,7 @@ def login_required(func):
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
auth = auth_active(AUTH_GROUP) auth = auth_active(AUTH_GROUP)
if auth["status"] and "username" not in session: if auth["status"] and "username" not in session:
flash(auth["msg"], "error") return response(error=True, message=auth["msg"])
return redirect(url_for("index"))
return func(*args, **kwargs) return func(*args, **kwargs)
return decorated_function return decorated_function
@ -342,11 +388,8 @@ def drive_create():
# Creating the image file # Creating the image file
process = file_cmd.create_new_image(file_name, file_type, size) process = file_cmd.create_new_image(file_name, file_type, size)
if process["status"]: if not process["status"]:
flash(_("Image file created: %(file_name)s", file_name=full_file_name)) return response(error=True, message=process["msg"])
else:
flash(process["msg"], "error")
return redirect(url_for("index"))
# Creating the drive properties file # Creating the drive properties file
prop_file_name = f"{file_name}.{file_type}.{PROPERTIES_SUFFIX}" prop_file_name = f"{file_name}.{file_type}.{PROPERTIES_SUFFIX}"
@ -358,12 +401,10 @@ def drive_create():
} }
process = file_cmd.write_drive_properties(prop_file_name, properties) process = file_cmd.write_drive_properties(prop_file_name, properties)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if not process["status"]:
flash(process["msg"]) return response(error=True, message=process["msg"])
return redirect(url_for("index"))
flash(process['msg'], "error") return response(message=_("Image file created: %(file_name)s", file_name=full_file_name))
return redirect(url_for("index"))
@APP.route("/drive/cdrom", methods=["POST"]) @APP.route("/drive/cdrom", methods=["POST"])
@ -389,11 +430,9 @@ def drive_cdrom():
process = file_cmd.write_drive_properties(file_name, properties) process = file_cmd.write_drive_properties(file_name, properties)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) return response(message=process["msg"])
return redirect(url_for("index"))
flash(process['msg'], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
@APP.route("/config/save", methods=["POST"]) @APP.route("/config/save", methods=["POST"])
@ -408,11 +447,9 @@ def config_save():
process = file_cmd.write_config(file_name) process = file_cmd.write_config(file_name)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) return response(message=process["msg"])
return redirect(url_for("index"))
flash(process['msg'], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
@APP.route("/config/load", methods=["POST"]) @APP.route("/config/load", methods=["POST"])
@ -427,24 +464,19 @@ def config_load():
process = file_cmd.read_config(file_name) process = file_cmd.read_config(file_name)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) return response(message=process["msg"])
return redirect(url_for("index"))
return response(error=True, message=process["msg"])
flash(process['msg'], "error")
return redirect(url_for("index"))
if "delete" in request.form: if "delete" in request.form:
process = file_cmd.delete_file(f"{CFG_DIR}/{file_name}") process = file_cmd.delete_file(f"{CFG_DIR}/{file_name}")
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) return response(message=process["msg"])
return redirect(url_for("index"))
flash(process['msg'], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
# The only reason we would reach here would be a Web UI bug. Will not localize. return response(error=True, message="Action field (load, delete) missing")
flash("Got an unhandled request (needs to be either load or delete)", "error")
return redirect(url_for("index"))
@APP.route("/logs/show", methods=["POST"]) @APP.route("/logs/show", methods=["POST"])
@ -455,14 +487,16 @@ def show_logs():
lines = request.form.get("lines") lines = request.form.get("lines")
scope = request.form.get("scope") scope = request.form.get("scope")
# TODO: Render logs in a template (issue #836) and structured JSON
returncode, logs = sys_cmd.get_logs(lines, scope) returncode, logs = sys_cmd.get_logs(lines, scope)
if returncode == 0: if returncode == 0:
headers = {"content-type": "text/plain"} headers = {"content-type": "text/plain"}
return logs, int(lines), headers return logs, headers
flash(_("An error occurred when fetching logs.")) return response(error=True, message=[
flash(logs, "stderr") (_("An error occurred when fetching logs."), "error"),
return redirect(url_for("index")) (logs, "stderr"),
])
@APP.route("/logs/level", methods=["POST"]) @APP.route("/logs/level", methods=["POST"])
@ -475,11 +509,9 @@ def log_level():
process = ractl_cmd.set_log_level(level) process = ractl_cmd.set_log_level(level)
if process["status"]: if process["status"]:
flash(_("Log level set to %(value)s", value=level)) return response(message=_("Log level set to %(value)s", value=level))
return redirect(url_for("index"))
flash(process["msg"], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
@APP.route("/scsi/attach_device", methods=["POST"]) @APP.route("/scsi/attach_device", methods=["POST"])
@ -505,11 +537,13 @@ def attach_device():
error_msg = _("Please follow the instructions at %(url)s", url=error_url) error_msg = _("Please follow the instructions at %(url)s", url=error_url)
if "interface" in params.keys(): if "interface" in params.keys():
# Note: is_bridge_configured returns False if the bridge is configured
bridge_status = is_bridge_configured(params["interface"]) bridge_status = is_bridge_configured(params["interface"])
if bridge_status: if bridge_status:
flash(bridge_status, "error") return response(error=True, message=[
flash(error_msg, "error") (bridge_status, "error"),
return redirect(url_for("index")) (error_msg, "error")
])
kwargs = { kwargs = {
"unit": int(unit), "unit": int(unit),
@ -519,16 +553,14 @@ def attach_device():
process = ractl_cmd.attach_device(scsi_id, **kwargs) process = ractl_cmd.attach_device(scsi_id, **kwargs)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(_( return response(message=_(
"Attached %(device_type)s to SCSI ID %(id_number)s LUN %(unit_number)s", "Attached %(device_type)s to SCSI ID %(id_number)s LUN %(unit_number)s",
device_type=get_device_name(device_type), device_type=get_device_name(device_type),
id_number=scsi_id, id_number=scsi_id,
unit_number=unit, unit_number=unit,
)) ))
return redirect(url_for("index"))
flash(process["msg"], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
@APP.route("/scsi/attach", methods=["POST"]) @APP.route("/scsi/attach", methods=["POST"])
@ -557,8 +589,7 @@ def attach_image():
process = file_cmd.read_drive_properties(drive_properties) process = file_cmd.read_drive_properties(drive_properties)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if not process["status"]: if not process["status"]:
flash(process["msg"], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
conf = process["conf"] conf = process["conf"]
kwargs["vendor"] = conf["vendor"] kwargs["vendor"] = conf["vendor"]
kwargs["product"] = conf["product"] kwargs["product"] = conf["product"]
@ -569,28 +600,31 @@ def attach_image():
process = ractl_cmd.attach_device(scsi_id, **kwargs) process = ractl_cmd.attach_device(scsi_id, **kwargs)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(_( response_messages = [(_(
"Attached %(file_name)s as %(device_type)s to " "Attached %(file_name)s as %(device_type)s to "
"SCSI ID %(id_number)s LUN %(unit_number)s", "SCSI ID %(id_number)s LUN %(unit_number)s",
file_name=file_name, file_name=file_name,
device_type=get_device_name(device_type), device_type=get_device_name(device_type),
id_number=scsi_id, id_number=scsi_id,
unit_number=unit, unit_number=unit,
)) ), "success")]
if int(file_size) % int(expected_block_size): if int(file_size) % int(expected_block_size):
flash(_( response_messages.append((_(
"The image file size %(file_size)s bytes is not a multiple of " "The image file size %(file_size)s bytes is not a multiple of "
"%(block_size)s. RaSCSI will ignore the trailing data. " "%(block_size)s. RaSCSI will ignore the trailing data. "
"The image may be corrupted, so proceed with caution.", "The image may be corrupted, so proceed with caution.",
file_size=file_size, file_size=file_size,
block_size=expected_block_size, block_size=expected_block_size,
), "error") ), "warning"))
return redirect(url_for("index"))
flash(_("Failed to attach %(file_name)s to SCSI ID %(id_number)s LUN %(unit_number)s", return response(message=response_messages)
file_name=file_name, id_number=scsi_id, unit_number=unit), "error")
flash(process["msg"], "error") return response(error=True, message=[
return redirect(url_for("index")) (_("Failed to attach %(file_name)s to SCSI ID %(id_number)s LUN %(unit_number)s",
file_name=file_name, id_number=scsi_id, unit_number=unit), "error"),
(process["msg"], "error"),
])
@APP.route("/scsi/detach_all", methods=["POST"]) @APP.route("/scsi/detach_all", methods=["POST"])
@ -601,11 +635,9 @@ def detach_all_devices():
""" """
process = ractl_cmd.detach_all() process = ractl_cmd.detach_all()
if process["status"]: if process["status"]:
flash(_("Detached all SCSI devices")) return response(message=_("Detached all SCSI devices"))
return redirect(url_for("index"))
flash(process["msg"], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
@APP.route("/scsi/detach", methods=["POST"]) @APP.route("/scsi/detach", methods=["POST"])
@ -618,14 +650,14 @@ def detach():
unit = request.form.get("unit") unit = request.form.get("unit")
process = ractl_cmd.detach_by_id(scsi_id, unit) process = ractl_cmd.detach_by_id(scsi_id, unit)
if process["status"]: if process["status"]:
flash(_("Detached SCSI ID %(id_number)s LUN %(unit_number)s", return response(message=_("Detached SCSI ID %(id_number)s LUN %(unit_number)s",
id_number=scsi_id, unit_number=unit)) id_number=scsi_id, unit_number=unit))
return redirect(url_for("index"))
flash(_("Failed to detach SCSI ID %(id_number)s LUN %(unit_number)s", return response(error=True, message=[
id_number=scsi_id, unit_number=unit), "error") (_("Failed to detach SCSI ID %(id_number)s LUN %(unit_number)s",
flash(process["msg"], "error") id_number=scsi_id, unit_number=unit), "error"),
return redirect(url_for("index")) (process["msg"], "error"),
])
@APP.route("/scsi/eject", methods=["POST"]) @APP.route("/scsi/eject", methods=["POST"])
@ -639,14 +671,15 @@ def eject():
process = ractl_cmd.eject_by_id(scsi_id, unit) process = ractl_cmd.eject_by_id(scsi_id, unit)
if process["status"]: if process["status"]:
flash(_("Ejected SCSI ID %(id_number)s LUN %(unit_number)s", return response(message=_("Ejected SCSI ID %(id_number)s LUN %(unit_number)s",
id_number=scsi_id, unit_number=unit)) id_number=scsi_id, unit_number=unit))
return redirect(url_for("index"))
flash(_("Failed to eject SCSI ID %(id_number)s LUN %(unit_number)s", return response(error=True, message=[
id_number=scsi_id, unit_number=unit), "error") (_("Failed to eject SCSI ID %(id_number)s LUN %(unit_number)s",
flash(process["msg"], "error") id_number=scsi_id, unit_number=unit), "error"),
return redirect(url_for("index")) (process["msg"], "error"),
])
@APP.route("/scsi/info", methods=["POST"]) @APP.route("/scsi/info", methods=["POST"])
def device_info(): def device_info():
@ -660,29 +693,48 @@ def device_info():
# First check if any device at all was returned # First check if any device at all was returned
if not devices["status"]: if not devices["status"]:
flash(devices["msg"], "error") return response(error=True, message=devices["msg"])
return redirect(url_for("index"))
# Looking at the first dict in list to get # Looking at the first dict in list to get
# the one and only device that should have been returned # the one and only device that should have been returned
device = devices["device_list"][0] device = devices["device_list"][0]
if str(device["id"]) == scsi_id: if str(device["id"]) == scsi_id:
flash(_("DEVICE INFO")) # TODO: Move the device info to the template instead of a flash message
flash("===========") message = "\n".join([
flash(_("SCSI ID: %(id_number)s", id_number=device["id"])) _("DEVICE INFO"),
flash(_("LUN: %(unit_number)s", unit_number=device["unit"])) "===========",
flash(_("Type: %(device_type)s", device_type=device["device_type"])) _("SCSI ID: %(id_number)s", id_number=device["id"]),
flash(_("Status: %(device_status)s", device_status=device["status"])) _("LUN: %(unit_number)s", unit_number=device["unit"]),
flash(_("File: %(image_file)s", image_file=device["image"])) _("Type: %(device_type)s", device_type=device["device_type"]),
flash(_("Parameters: %(value)s", value=device["params"])) _("Status: %(device_status)s", device_status=device["status"]),
flash(_("Vendor: %(value)s", value=device["vendor"])) _("File: %(image_file)s", image_file=device["image"]),
flash(_("Product: %(value)s", value=device["product"])) _("Parameters: %(value)s", value=device["params"]),
flash(_("Revision: %(revision_number)s", revision_number=device["revision"])) _("Vendor: %(value)s", value=device["vendor"]),
flash(_("Block Size: %(value)s bytes", value=device["block_size"])) _("Product: %(value)s", value=device["product"]),
flash(_("Image Size: %(value)s bytes", value=device["size"])) _("Revision: %(revision_number)s", revision_number=device["revision"]),
return redirect(url_for("index")) _("Block Size: %(value)s bytes", value=device["block_size"]),
_("Image Size: %(value)s bytes", value=device["size"]),
])
# Don't send redundant "info" message with the JSON response
if request.headers.get("accept") == "application/json":
return response(device_info={
"scsi_id": device["id"],
"lun": device["unit"],
"device_type": device["device_type"],
"status": device["status"],
"file": device["image"],
"parameters": device["params"],
"vendor": device["vendor"],
"product": device["product"],
"revision": device["revision"],
"block_size": device["block_size"],
"size": device["size"],
})
return response(message=[(message, "info")])
return response(error=True, message=devices["msg"])
flash(devices["msg"], "error")
return redirect(url_for("index"))
@APP.route("/scsi/reserve", methods=["POST"]) @APP.route("/scsi/reserve", methods=["POST"])
@login_required @login_required
@ -697,12 +749,13 @@ def reserve_id():
process = ractl_cmd.reserve_scsi_ids(reserved_ids) process = ractl_cmd.reserve_scsi_ids(reserved_ids)
if process["status"]: if process["status"]:
RESERVATIONS[int(scsi_id)] = memo RESERVATIONS[int(scsi_id)] = memo
flash(_("Reserved SCSI ID %(id_number)s", id_number=scsi_id)) return response(message=_("Reserved SCSI ID %(id_number)s", id_number=scsi_id))
return redirect(url_for("index"))
return response(error=True, message=[
(_("Failed to reserve SCSI ID %(id_number)s", id_number=scsi_id), "error"),
(process["msg"], "error"),
])
flash(_("Failed to reserve SCSI ID %(id_number)s", id_number=scsi_id))
flash(process["msg"], "error")
return redirect(url_for("index"))
@APP.route("/scsi/release", methods=["POST"]) @APP.route("/scsi/release", methods=["POST"])
@login_required @login_required
@ -716,12 +769,12 @@ def release_id():
process = ractl_cmd.reserve_scsi_ids(reserved_ids) process = ractl_cmd.reserve_scsi_ids(reserved_ids)
if process["status"]: if process["status"]:
RESERVATIONS[int(scsi_id)] = "" RESERVATIONS[int(scsi_id)] = ""
flash(_("Released the reservation for SCSI ID %(id_number)s", id_number=scsi_id)) return response(message=_("Released the reservation for SCSI ID %(id_number)s", id_number=scsi_id))
return redirect(url_for("index"))
flash(_("Failed to release the reservation for SCSI ID %(id_number)s", id_number=scsi_id)) return response(error=True, message=[
flash(process["msg"], "error") (_("Failed to release the reservation for SCSI ID %(id_number)s", id_number=scsi_id), "error"),
return redirect(url_for("index")) (process["msg"], "error"),
])
@APP.route("/pi/reboot", methods=["POST"]) @APP.route("/pi/reboot", methods=["POST"])
@ -731,7 +784,7 @@ def restart():
Restarts the Pi Restarts the Pi
""" """
ractl_cmd.shutdown_pi("reboot") ractl_cmd.shutdown_pi("reboot")
return redirect(url_for("index")) return response()
@APP.route("/pi/shutdown", methods=["POST"]) @APP.route("/pi/shutdown", methods=["POST"])
@ -741,7 +794,7 @@ def shutdown():
Shuts down the Pi Shuts down the Pi
""" """
ractl_cmd.shutdown_pi("system") ractl_cmd.shutdown_pi("system")
return redirect(url_for("index")) return response()
@APP.route("/files/download_to_iso", methods=["POST"]) @APP.route("/files/download_to_iso", methods=["POST"])
@ -753,16 +806,18 @@ def download_to_iso():
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
url = request.form.get("url") url = request.form.get("url")
iso_args = request.form.get("type").split() iso_args = request.form.get("type").split()
response_messages = []
process = file_cmd.download_file_to_iso(url, *iso_args) process = file_cmd.download_file_to_iso(url, *iso_args)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if not process["status"]:
flash(process["msg"]) return response(error=True, message=[
flash(_("Saved image as: %(file_name)s", file_name=process['file_name'])) (_("Failed to create CD-ROM image from %(url)s", url=url), "error"),
else: (process["msg"], "error"),
flash(_("Failed to create CD-ROM image from %(url)s", url=url), "error") ])
flash(process["msg"], "error")
return redirect(url_for("index")) response_messages.append((process["msg"], "success"))
response_messages.append((_("Saved image as: %(file_name)s", file_name=process['file_name']), "success"))
process_attach = ractl_cmd.attach_device( process_attach = ractl_cmd.attach_device(
scsi_id, scsi_id,
@ -771,13 +826,13 @@ def download_to_iso():
) )
process_attach = ReturnCodeMapper.add_msg(process_attach) process_attach = ReturnCodeMapper.add_msg(process_attach)
if process_attach["status"]: if process_attach["status"]:
flash(_("Attached to SCSI ID %(id_number)s", id_number=scsi_id)) response_messages.append((_("Attached to SCSI ID %(id_number)s", id_number=scsi_id), "success"))
return redirect(url_for("index")) return response(message=response_messages)
flash(_("Failed to attach image to SCSI ID %(id_number)s. Try attaching it manually.", return response(error=True, message=[
id_number=scsi_id), "error") (_("Failed to attach image to SCSI ID %(id_number)s. Try attaching it manually.", id_number=scsi_id), "error"),
flash(process_attach["msg"], "error") (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"])
@ -791,12 +846,12 @@ def download_img():
process = file_cmd.download_to_dir(url, server_info["image_dir"], Path(url).name) process = file_cmd.download_to_dir(url, server_info["image_dir"], Path(url).name)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) return response(message=process["msg"])
return redirect(url_for("index"))
flash(_("Failed to download file from %(url)s", url=url), "error") return response(error=True, message=[
flash(process["msg"], "error") (_("Failed to download file from %(url)s", url=url), "error"),
return redirect(url_for("index")) (process["msg"], "error"),
])
@APP.route("/files/download_to_afp", methods=["POST"]) @APP.route("/files/download_to_afp", methods=["POST"])
@ -810,12 +865,12 @@ def download_afp():
process = file_cmd.download_to_dir(url, AFP_DIR, file_name) process = file_cmd.download_to_dir(url, AFP_DIR, file_name)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) return response(message=process["msg"])
return redirect(url_for("index"))
flash(_("Failed to download file from %(url)s", url=url), "error") return response(error=True, message=[
flash(process["msg"], "error") (_("Failed to download file from %(url)s", url=url), "error"),
return redirect(url_for("index")) (process["msg"], "error"),
])
@APP.route("/files/upload", methods=["POST"]) @APP.route("/files/upload", methods=["POST"])
@ -846,11 +901,13 @@ def create_file():
process = file_cmd.create_new_image(file_name, file_type, size) process = file_cmd.create_new_image(file_name, file_type, size)
if process["status"]: if process["status"]:
flash(_("Image file created: %(file_name)s", file_name=full_file_name)) return response(
return redirect(url_for("index")) status_code=201,
message=_("Image file created: %(file_name)s", file_name=full_file_name),
image=full_file_name,
)
flash(process["msg"], "error") return response(error=True, message=process["msg"])
return redirect(url_for("index"))
@APP.route("/files/download", methods=["POST"]) @APP.route("/files/download", methods=["POST"])
@ -872,26 +929,23 @@ def delete():
file_name = request.form.get("file_name") file_name = request.form.get("file_name")
process = file_cmd.delete_image(file_name) process = file_cmd.delete_image(file_name)
if process["status"]: if not process["status"]:
flash(_("Image file deleted: %(file_name)s", file_name=file_name)) return response(error=True, message=process["msg"])
else:
flash(process["msg"], "error") response_messages = [
return redirect(url_for("index")) (_("Image file deleted: %(file_name)s", file_name=file_name), "success")]
# Delete the drive properties file, if it exists # Delete the drive properties file, if it exists
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(): if Path(prop_file_path).is_file():
process = file_cmd.delete_file(prop_file_path) process = file_cmd.delete_file(prop_file_path)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) response_messages.append((process["msg"], "success"))
return redirect(url_for("index")) else:
response_messages.append((process["msg"], "error"))
flash(process["msg"], "error") return response(message=response_messages)
return redirect(url_for("index"))
return redirect(url_for("index"))
@APP.route("/files/rename", methods=["POST"]) @APP.route("/files/rename", methods=["POST"])
@ -904,11 +958,11 @@ def rename():
new_file_name = request.form.get("new_file_name") new_file_name = request.form.get("new_file_name")
process = file_cmd.rename_image(file_name, new_file_name) process = file_cmd.rename_image(file_name, new_file_name)
if process["status"]: if not process["status"]:
flash(_("Image file renamed to: %(file_name)s", file_name=new_file_name)) return response(error=True, message=process["msg"])
else:
flash(process["msg"], "error") response_messages = [
return redirect(url_for("index")) (_("Image file renamed to: %(file_name)s", file_name=new_file_name), "success")]
# Rename the drive properties file, if it exists # Rename the drive properties file, if it exists
prop_file_path = f"{CFG_DIR}/{file_name}.{PROPERTIES_SUFFIX}" prop_file_path = f"{CFG_DIR}/{file_name}.{PROPERTIES_SUFFIX}"
@ -917,13 +971,11 @@ def rename():
process = file_cmd.rename_file(prop_file_path, new_prop_file_path) process = file_cmd.rename_file(prop_file_path, new_prop_file_path)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) response_messages.append((process["msg"], "success"))
return redirect(url_for("index")) else:
response_messages.append((process["msg"], "error"))
flash(process["msg"], "error") return response(message=response_messages)
return redirect(url_for("index"))
return redirect(url_for("index"))
@APP.route("/files/copy", methods=["POST"]) @APP.route("/files/copy", methods=["POST"])
@ -936,11 +988,11 @@ def copy():
new_file_name = request.form.get("copy_file_name") new_file_name = request.form.get("copy_file_name")
process = file_cmd.copy_image(file_name, new_file_name) process = file_cmd.copy_image(file_name, new_file_name)
if process["status"]: if not process["status"]:
flash(_("Copy of image file saved as: %(file_name)s", file_name=new_file_name)) return response(error=True, message=process["msg"])
else:
flash(process["msg"], "error") response_messages = [
return redirect(url_for("index")) (_("Copy of image file saved as: %(file_name)s", file_name=new_file_name), "success")]
# Create a copy of the drive properties file, if it exists # Create a copy of the drive properties file, if it exists
prop_file_path = f"{CFG_DIR}/{file_name}.{PROPERTIES_SUFFIX}" prop_file_path = f"{CFG_DIR}/{file_name}.{PROPERTIES_SUFFIX}"
@ -949,13 +1001,11 @@ def copy():
process = file_cmd.copy_file(prop_file_path, new_prop_file_path) process = file_cmd.copy_file(prop_file_path, new_prop_file_path)
process = ReturnCodeMapper.add_msg(process) process = ReturnCodeMapper.add_msg(process)
if process["status"]: if process["status"]:
flash(process["msg"]) response_messages.append((process["msg"], "success"))
return redirect(url_for("index")) else:
response_messages.append((process["msg"], "error"))
flash(process["msg"], "error") return response(message=response_messages)
return redirect(url_for("index"))
return redirect(url_for("index"))
@APP.route("/files/extract_image", methods=["POST"]) @APP.route("/files/extract_image", methods=["POST"])
@ -974,23 +1024,23 @@ def extract_image():
) )
if extract_result["return_code"] == ReturnCodes.EXTRACTIMAGE_SUCCESS: if extract_result["return_code"] == ReturnCodes.EXTRACTIMAGE_SUCCESS:
flash(ReturnCodeMapper.add_msg(extract_result).get("msg")) response_messages = [(ReturnCodeMapper.add_msg(extract_result).get("msg"), "success")]
for properties_file in extract_result["properties_files_moved"]: for properties_file in extract_result["properties_files_moved"]:
if properties_file["status"]: if properties_file["status"]:
flash(_("Properties file %(file)s moved to %(directory)s", response_messages.append((_("Properties file %(file)s moved to %(directory)s",
file=properties_file['name'], file=properties_file['name'],
directory=CFG_DIR directory=CFG_DIR
)) ), "success"))
else: else:
flash(_("Failed to move properties file %(file)s to %(directory)s", response_messages.append((_("Failed to move properties file %(file)s to %(directory)s",
file=properties_file['name'], file=properties_file['name'],
directory=CFG_DIR directory=CFG_DIR
), "error") ), "error"))
else:
flash(ReturnCodeMapper.add_msg(extract_result).get("msg"), "error")
return redirect(url_for("index")) return response(message=response_messages)
return response(error=True, message=ReturnCodeMapper.add_msg(extract_result).get("msg"))
@APP.route("/language", methods=["POST"]) @APP.route("/language", methods=["POST"])
@ -1006,8 +1056,7 @@ def change_language():
language = Locale.parse(locale) language = Locale.parse(locale)
language_name = language.get_language_name(locale) language_name = language.get_language_name(locale)
flash(_("Changed Web Interface language to %(locale)s", locale=language_name)) return response(message=_("Changed Web Interface language to %(locale)s", locale=language_name))
return redirect(url_for("index"))
@APP.before_first_request @APP.before_first_request

View File

@ -62,8 +62,7 @@ if ! test -e venv; then
pip3 install wheel pip3 install wheel
pip3 install -r requirements.txt pip3 install -r requirements.txt
git rev-parse --is-inside-work-tree &> /dev/null if git rev-parse --is-inside-work-tree &> /dev/null; then
if [[ $? -eq 0 ]]; then
git rev-parse HEAD > current git rev-parse HEAD > current
fi fi
fi fi

View File

@ -0,0 +1,78 @@
import pytest
import uuid
CFG_DIR = "/home/pi/.config/rascsi"
IMAGES_DIR = "/home/pi/images"
AFP_DIR = "/home/pi/afpshare"
SCSI_ID = 6
FILE_SIZE_1_MIB = 1048576
STATUS_SUCCESS = "success"
STATUS_ERROR = "error"
@pytest.fixture(scope="function")
def create_test_image(request, http_client):
images = []
def create(image_type="hds", size=1, auto_delete=True):
file_prefix = str(uuid.uuid4())
file_name = f"{file_prefix}.{image_type}"
response = http_client.post(
"/files/create",
data={
"file_name": file_prefix,
"type": image_type,
"size": size,
},
)
if response.json()["status"] != STATUS_SUCCESS:
raise Exception("Failed to create temporary image")
if auto_delete:
images.append(file_name)
return file_name
def delete():
for image in images:
http_client.post("/files/delete", data={"file_name": image})
request.addfinalizer(delete)
return create
@pytest.fixture(scope="function")
def list_files(http_client):
def files():
return [f["name"] for f in http_client.get("/").json()["data"]["files"]]
return files
@pytest.fixture(scope="function")
def list_attached_images(http_client):
def files():
return http_client.get("/").json()["data"]["attached_images"]
return files
@pytest.fixture(scope="function")
def delete_file(http_client):
def delete(file_name):
http_client.post("/files/delete", data={"file_name": file_name})
return delete
@pytest.fixture(scope="function")
def detach_devices(http_client):
def detach():
response = http_client.post("/scsi/detach_all")
if response.json()["status"] == STATUS_SUCCESS:
return True
raise Exception("Failed to detach SCSI devices")
return detach

View File

@ -0,0 +1,43 @@
from conftest import STATUS_SUCCESS, STATUS_ERROR
# route("/login", methods=["POST"])
def test_login_with_valid_credentials(pytestconfig, http_client_unauthenticated):
response = http_client_unauthenticated.post(
"/login",
data={
"username": pytestconfig.getoption("rascsi_username"),
"password": pytestconfig.getoption("rascsi_password"),
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert "env" in response_data["data"]
# route("/login", methods=["POST"])
def test_login_with_invalid_credentials(http_client_unauthenticated):
response = http_client_unauthenticated.post(
"/login",
data={
"username": "__INVALID_USER__",
"password": "__INVALID_PASS__",
},
)
response_data = response.json()
assert response.status_code == 401
assert response_data["status"] == STATUS_ERROR
assert response_data["messages"][0]["message"] == (
"You must log in with valid credentials for a user in the 'rascsi' group"
)
# route("/logout")
def test_logout(http_client):
response = http_client.get("/logout")
assert response.status_code == 200

View File

@ -0,0 +1,227 @@
import pytest
from conftest import (
IMAGES_DIR,
SCSI_ID,
FILE_SIZE_1_MIB,
STATUS_SUCCESS,
)
# route("/scsi/attach", methods=["POST"])
def test_attach_image(http_client, create_test_image, detach_devices):
test_image = create_test_image()
response = http_client.post(
"/scsi/attach",
data={
"file_name": test_image,
"file_size": FILE_SIZE_1_MIB,
"scsi_id": SCSI_ID,
"unit": 0,
"type": "SCHD",
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == (
f"Attached {test_image} as Hard Disk to SCSI ID {SCSI_ID} LUN 0"
)
# Cleanup
detach_devices()
# route("/scsi/attach_device", methods=["POST"])
@pytest.mark.parametrize(
"device_name,device_config",
[
# TODO: Fix networking in container, else SCBR attachment fails
# ("X68000 Host Bridge", {"type": "SCBR", "interface": "eth0", "inet": "10.10.20.1/24"}),
("DaynaPORT SCSI/Link", {"type": "SCDP", "interface": "eth0", "inet": "10.10.20.1/24"}),
("Host Services", {"type": "SCHS"}),
("Printer", {"type": "SCLP", "timeout": 30, "cmd": "lp -oraw %f"}),
],
)
def test_attach_device(http_client, detach_devices, device_name, device_config):
device_config["scsi_id"] = SCSI_ID
device_config["unit"] = 0
response = http_client.post(
"/scsi/attach_device",
data=device_config,
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == (
f"Attached {device_name} to SCSI ID {SCSI_ID} LUN 0"
)
# Cleanup
detach_devices()
# route("/scsi/detach", methods=["POST"])
def test_detach_device(http_client, create_test_image):
test_image = create_test_image()
http_client.post(
"/scsi/attach",
data={
"file_name": test_image,
"file_size": FILE_SIZE_1_MIB,
"scsi_id": SCSI_ID,
"unit": 0,
"type": "SCHD",
},
)
response = http_client.post(
"/scsi/detach",
data={
"scsi_id": SCSI_ID,
"unit": 0,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Detached SCSI ID {SCSI_ID} LUN 0"
# route("/scsi/detach_all", methods=["POST"])
def test_detach_all_devices(http_client, create_test_image, list_attached_images):
test_images = []
scsi_ids = [4, 5, 6]
for scsi_id in scsi_ids:
test_image = create_test_image()
test_images.append(test_image)
http_client.post(
"/scsi/attach",
data={
"file_name": test_image,
"file_size": FILE_SIZE_1_MIB,
"scsi_id": scsi_id,
"unit": 0,
"type": "SCHD",
},
)
assert list_attached_images() == test_images
response = http_client.post("/scsi/detach_all")
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert list_attached_images() == []
# route("/scsi/eject", methods=["POST"])
def test_eject_device(http_client, create_test_image, detach_devices):
test_image = create_test_image()
http_client.post(
"/scsi/attach",
data={
"file_name": test_image,
"file_size": FILE_SIZE_1_MIB,
"scsi_id": SCSI_ID,
"unit": 0,
"type": "SCCD", # CD-ROM
},
)
response = http_client.post(
"/scsi/eject",
data={
"scsi_id": SCSI_ID,
"unit": 0,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Ejected SCSI ID {SCSI_ID} LUN 0"
# Cleanup
detach_devices()
# route("/scsi/info", methods=["POST"])
def test_show_device_info(http_client, create_test_image, detach_devices):
test_image = create_test_image()
http_client.post(
"/scsi/attach",
data={
"file_name": test_image,
"file_size": FILE_SIZE_1_MIB,
"scsi_id": SCSI_ID,
"unit": 0,
"type": "SCHD",
},
)
response = http_client.post(
"/scsi/info",
data={
"scsi_id": SCSI_ID,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert "device_info" in response_data["data"]
assert response_data["data"]["device_info"]["file"] == f"{IMAGES_DIR}/{test_image}"
# Cleanup
detach_devices()
# route("/scsi/reserve", methods=["POST"])
# route("/scsi/release", methods=["POST"])
def test_reserve_and_release_device(http_client):
scsi_id = 0
response = http_client.post(
"/scsi/reserve",
data={
"scsi_id": scsi_id,
"memo": "TEST",
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Reserved SCSI ID {scsi_id}"
response = http_client.post(
"/scsi/release",
data={
"scsi_id": scsi_id,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == (
f"Released the reservation for SCSI ID {scsi_id}"
)

View File

@ -0,0 +1,317 @@
import pytest
import uuid
import os
from conftest import (
IMAGES_DIR,
AFP_DIR,
SCSI_ID,
FILE_SIZE_1_MIB,
STATUS_SUCCESS,
)
# route("/files/create", methods=["POST"])
def test_create_file(http_client, list_files, delete_file):
file_prefix = str(uuid.uuid4())
file_name = f"{file_prefix}.hds"
response = http_client.post(
"/files/create",
data={
"file_name": file_prefix,
"type": "hds",
"size": 1,
},
)
response_data = response.json()
assert response.status_code == 201
assert response_data["status"] == STATUS_SUCCESS
assert response_data["data"]["image"] == file_name
assert response_data["messages"][0]["message"] == f"Image file created: {file_name}"
assert file_name in list_files()
# Cleanup
delete_file(file_name)
# route("/files/rename", methods=["POST"])
def test_rename_file(http_client, create_test_image, list_files, delete_file):
original_file = create_test_image(auto_delete=False)
renamed_file = f"{uuid.uuid4()}.rename"
response = http_client.post(
"/files/rename",
data={"file_name": original_file, "new_file_name": renamed_file},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Image file renamed to: {renamed_file}"
assert renamed_file in list_files()
# Cleanup
delete_file(renamed_file)
# route("/files/copy", methods=["POST"])
def test_copy_file(http_client, create_test_image, list_files, delete_file):
original_file = create_test_image()
copy_file = f"{uuid.uuid4()}.copy"
response = http_client.post(
"/files/copy",
data={
"file_name": original_file,
"copy_file_name": copy_file,
},
)
response_data = response.json()
files = list_files()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Copy of image file saved as: {copy_file}"
assert original_file in files
assert copy_file in files
# Cleanup
delete_file(copy_file)
# route("/files/delete", methods=["POST"])
def test_delete_file(http_client, create_test_image, list_files):
file_name = create_test_image()
response = http_client.post("/files/delete", data={"file_name": file_name})
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Image file deleted: {file_name}"
assert file_name not in list_files()
# route("/files/extract_image", methods=["POST"])
@pytest.mark.parametrize(
"archive_file_name,image_file_name",
[
("test_image.zip", "test_image_from_zip.hds"),
("test_image.sit", "test_image_from_sit.hds"),
("test_image.7z", "test_image_from_7z.hds"),
],
)
def test_extract_file(
httpserver, http_client, list_files, delete_file, archive_file_name, image_file_name
):
http_path = f"/images/{archive_file_name}"
url = httpserver.url_for(http_path)
with open(f"tests/assets/{archive_file_name}", mode="rb") as file:
zip_file_data = file.read()
httpserver.expect_request(http_path).respond_with_data(
zip_file_data,
mimetype="application/octet-stream",
)
http_client.post(
"/files/download_to_images",
data={
"url": url,
},
)
response = http_client.post(
"/files/extract_image",
data={
"archive_file": archive_file_name,
"archive_members": image_file_name,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == "Extracted 1 file(s)"
assert image_file_name in list_files()
# Cleanup
delete_file(archive_file_name)
delete_file(image_file_name)
# route("/files/upload", methods=["POST"])
def test_upload_file(http_client, delete_file):
file_name = f"{uuid.uuid4()}.test"
with open("tests/assets/test_image.hds", mode="rb") as file:
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0, 0)
number_of_chunks = 4
# Note: The test file needs to be cleanly divisible by the chunk size
chunk_size = int(file_size / number_of_chunks)
for chunk_number in range(0, 4):
if chunk_number == 0:
chunk_byte_offset = 0
else:
chunk_byte_offset = chunk_number * chunk_size
form_data = {
"dzuuid": str(uuid.uuid4()),
"dzchunkindex": chunk_number,
"dzchunksize": chunk_size,
"dzchunkbyteoffset": chunk_byte_offset,
"dztotalfilesize": file_size,
"dztotalchunkcount": number_of_chunks,
}
file_data = {"file": (file_name, file.read(chunk_size))}
response = http_client.post(
"/files/upload",
data=form_data,
files=file_data,
)
assert response.status_code == 200
assert response.text == "File upload successful!"
file = [f for f in http_client.get("/").json()["data"]["files"] if f["name"] == file_name][0]
assert file["size"] == file_size
# Cleanup
delete_file(file_name)
# route("/files/download", methods=["POST"])
def test_download_file(http_client, create_test_image):
file_name = create_test_image()
response = http_client.post("/files/download", data={"file": f"{IMAGES_DIR}/{file_name}"})
assert response.status_code == 200
assert response.headers["content-type"] == "application/octet-stream"
assert response.headers["content-disposition"] == f"attachment; filename={file_name}"
assert response.headers["content-length"] == str(FILE_SIZE_1_MIB)
# route("/files/download_to_afp", methods=["POST"])
def test_download_url_to_afp_dir(httpserver, http_client):
file_name = str(uuid.uuid4())
http_path = f"/images/{file_name}"
url = httpserver.url_for(http_path)
with open("tests/assets/test_image.hds", mode="rb") as file:
file_data = file.read()
httpserver.expect_request(http_path).respond_with_data(
file_data,
mimetype="application/octet-stream",
)
response = http_client.post(
"/files/download_to_afp",
data={
"url": url,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"{file_name} downloaded to {AFP_DIR}"
# route("/files/download_to_images", methods=["POST"])
def test_download_url_to_images_dir(httpserver, http_client, list_files, delete_file):
file_name = str(uuid.uuid4())
http_path = f"/images/{file_name}"
url = httpserver.url_for(http_path)
with open("tests/assets/test_image.hds", mode="rb") as file:
test_file_data = file.read()
httpserver.expect_request(http_path).respond_with_data(
test_file_data,
mimetype="application/octet-stream",
)
response = http_client.post(
"/files/download_to_images",
data={
"url": url,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert file_name in list_files()
assert response_data["messages"][0]["message"] == f"{file_name} downloaded to {IMAGES_DIR}"
# Cleanup
delete_file(file_name)
# route("/files/download_to_iso", methods=["POST"])
def test_download_url_to_iso(
httpserver,
http_client,
list_files,
list_attached_images,
detach_devices,
delete_file,
):
test_file_name = str(uuid.uuid4())
iso_file_name = f"{test_file_name}.iso"
http_path = f"/images/{test_file_name}"
url = httpserver.url_for(http_path)
with open("tests/assets/test_image.hds", mode="rb") as file:
test_file_data = file.read()
httpserver.expect_request(http_path).respond_with_data(
test_file_data,
mimetype="application/octet-stream",
)
response = http_client.post(
"/files/download_to_iso",
data={
"scsi_id": SCSI_ID,
"type": "-hfs",
"url": url,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert iso_file_name in list_files()
assert iso_file_name in list_attached_images()
m = response_data["messages"]
assert m[0]["message"] == 'Created CD-ROM ISO image with arguments "-hfs"'
assert m[1]["message"] == f"Saved image as: {IMAGES_DIR}/{iso_file_name}"
assert m[2]["message"] == f"Attached to SCSI ID {SCSI_ID}"
# Cleanup
detach_devices()
delete_file(iso_file_name)

View File

@ -0,0 +1,99 @@
import uuid
from conftest import (
CFG_DIR,
FILE_SIZE_1_MIB,
STATUS_SUCCESS,
)
# route("/")
def test_index(http_client):
response = http_client.get("/")
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert "devices" in response_data["data"]
# route("/env")
def test_get_env_info(http_client):
response = http_client.get("/env")
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert "running_env" in response_data["data"]
# route("/pwa/<path:pwa_path>")
def test_pwa_route(http_client):
response = http_client.get("/pwa/favicon.ico")
assert response.status_code == 200
assert response.headers["content-disposition"] == "inline; filename=favicon.ico"
# route("/drive/list", methods=["GET"])
def test_show_named_drive_presets(http_client):
response = http_client.get("/drive/list")
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert "cd_conf" in response_data["data"]
assert "hd_conf" in response_data["data"]
assert "rm_conf" in response_data["data"]
# route("/drive/cdrom", methods=["POST"])
def test_create_cdrom_properties_file(http_client):
file_name = f"{uuid.uuid4()}.iso"
response = http_client.post(
"/drive/cdrom",
data={
"vendor": "TEST_AAA",
"product": "TEST_BBB",
"revision": "1.0A",
"block_size": 2048,
"file_name": file_name,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == (
f"File created: {CFG_DIR}/{file_name}.properties"
)
# route("/drive/create", methods=["POST"])
def test_create_image_with_properties_file(http_client, delete_file):
file_prefix = str(uuid.uuid4())
file_name = f"{file_prefix}.hds"
response = http_client.post(
"/drive/create",
data={
"vendor": "TEST_AAA",
"product": "TEST_BBB",
"revision": "1.0A",
"block_size": 512,
"size": FILE_SIZE_1_MIB,
"file_type": "hds",
"file_name": file_prefix,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Image file created: {file_name}"
# Cleanup
delete_file(file_name)

View File

@ -0,0 +1,149 @@
import pytest
import uuid
from conftest import CFG_DIR, STATUS_SUCCESS
# route("/language", methods=["POST"])
@pytest.mark.parametrize(
"locale,confirm_message",
[
("de", "Webinterface-Sprache auf Deutsch geändert"),
("es", "Se ha cambiado el lenguaje de la Interfaz Web a español"),
("fr", "Langue de linterface web changée pour français"),
("sv", "Bytte webbgränssnittets språk till svenska"),
("en", "Changed Web Interface language to English"),
],
)
def test_set_language(http_client, locale, confirm_message):
response = http_client.post(
"/language",
data={
"locale": locale,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == confirm_message
# route("/logs/level", methods=["POST"])
@pytest.mark.parametrize("level", ["trace", "debug", "info", "warn", "err", "critical", "off"])
def test_set_log_level(http_client, level):
response = http_client.post(
"/logs/level",
data={
"level": level,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == f"Log level set to {level}"
# Cleanup
http_client.post(
"/logs/level",
data={
"level": "debug",
},
)
# route("/logs/show", methods=["POST"])
def test_show_logs(http_client):
response = http_client.post(
"/logs/show",
data={
"lines": 100,
"scope": "",
},
)
assert response.status_code == 200
assert response.headers["content-type"] == "text/plain"
# route("/config/save", methods=["POST"])
# route("/config/load", methods=["POST"])
def test_save_load_and_delete_configs(http_client):
config_name = str(uuid.uuid4())
config_json_file = f"{config_name}.json"
reserved_scsi_id = 0
reservation_memo = str(uuid.uuid4())
# Confirm the initial state
assert http_client.get("/").json()["data"]["RESERVATIONS"][0] == ""
# Save the initial state to a config
response = http_client.post(
"/config/save",
data={
"name": config_name,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == (
f"File created: {CFG_DIR}/{config_json_file}"
)
assert config_json_file in http_client.get("/").json()["data"]["config_files"]
# Modify the state
http_client.post(
"/scsi/reserve",
data={
"scsi_id": reserved_scsi_id,
"memo": reservation_memo,
},
)
assert http_client.get("/").json()["data"]["RESERVATIONS"][0] == reservation_memo
# Load the saved config
response = http_client.post(
"/config/load",
data={
"name": config_json_file,
"load": True,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == (
f"Loaded configurations from: {CFG_DIR}/{config_json_file}"
)
# Confirm the application has returned to its initial state
assert http_client.get("/").json()["data"]["RESERVATIONS"][0] == ""
# Delete the saved config
response = http_client.post(
"/config/load",
data={
"name": config_json_file,
"delete": True,
},
)
response_data = response.json()
assert response.status_code == 200
assert response_data["status"] == STATUS_SUCCESS
assert response_data["messages"][0]["message"] == (
f"File deleted: {CFG_DIR}/{config_json_file}"
)
assert config_json_file not in http_client.get("/").json()["data"]["config_files"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,66 @@
import pytest
import requests
def pytest_addoption(parser):
parser.addoption("--base_url", action="store", default="http://localhost:8080")
parser.addoption("--httpserver_host", action="store", default="host.docker.internal")
parser.addoption("--httpserver_listen_address", action="store", default="127.0.0.1")
parser.addoption("--rascsi_username", action="store", default="pi")
parser.addoption("--rascsi_password", action="store", default="rascsi")
@pytest.fixture(scope="session")
def httpserver_listen_address(pytestconfig):
return (pytestconfig.getoption("httpserver_listen_address"), 0)
@pytest.fixture(scope="function", autouse=True)
def set_httpserver_hostname(pytestconfig, httpserver):
# The HTTP requests are made by Python from within the container so we need
# httpserver.url_for to generate URLs which point to the Docker host
httpserver.host = pytestconfig.getoption("httpserver_host")
@pytest.fixture(scope="session", autouse=True)
def ensure_all_devices_detached(create_http_client):
http_client = create_http_client()
http_client.post("/scsi/detach_all")
@pytest.fixture(scope="session")
def create_http_client(pytestconfig):
def create(authenticate=True):
session = requests.Session()
session.headers.update({"Accept": "application/json"})
session.original_request = session.request
def relative_request(method, url, *args, **kwargs):
if url[:4] != "http":
url = pytestconfig.getoption("base_url") + url
return session.original_request(method, url, *args, **kwargs)
session.request = relative_request
if authenticate:
session.post(
"/login",
data={
"username": pytestconfig.getoption("rascsi_username"),
"password": pytestconfig.getoption("rascsi_password"),
},
)
return session
return create
@pytest.fixture(scope="function")
def http_client(create_http_client):
return create_http_client(authenticate=True)
@pytest.fixture(scope="function")
def http_client_unauthenticated(create_http_client):
return create_http_client(authenticate=False)