# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2018-2022 Andrew Rechnitzer
# Copyright (C) 2018 Elvis Cai
# Copyright (C) 2019-2025 Colin B. Macdonald
# Copyright (C) 2020 Victoria Schuster
# Copyright (C) 2020 Forest Kobayashi
# Copyright (C) 2021 Peter Lee
# Copyright (C) 2022 Edith Coates
# Copyright (C) 2024 Bryan Tanady
"""Plom's Chooser dialog."""
from __future__ import annotations
__copyright__ = "Copyright (C) 2018-2024 Andrew Rechnitzer, Colin B. Macdonald, et al"
__credits__ = "The Plom Project Developers"
__license__ = "AGPL-3.0-or-later"
import logging
import shutil
import sys
import tempfile
import time
from importlib import resources
from pathlib import Path
from typing import Any
import arrow
import platformdirs
from packaging.version import Version
if sys.version_info < (3, 11):
import tomli as tomllib
else:
import tomllib
import tomlkit
import urllib3
from PyQt6 import QtGui, uic
from PyQt6.QtCore import pyqtSlot
from PyQt6.QtWidgets import QDialog, QMessageBox
from plom import __version__
from plom import Plom_API_Version
from plom import Default_Port
from plom import get_question_label
from plom.plom_exceptions import (
PlomException,
PlomSeriousException,
PlomBenignException,
PlomAPIException,
PlomAuthenticationException,
PlomExistingLoginException,
PlomServerNotReady,
PlomSSLError,
PlomNoServerSupportException,
)
from plom.messenger import Messenger, ManagerMessenger
from . import MarkerClient, IDClient
from . import ui_files
from .downloader import Downloader
from .about_dialog import show_about_dialog
from .useful_classes import ErrorMsg, WarnMsg, InfoMsg, WarningQuestion
from .useful_classes import ClientSettingsDialog
log = logging.getLogger("client")
logdir = platformdirs.user_log_path("plom", "PlomGrading.org")
cfgdir = platformdirs.user_config_path("plom", "PlomGrading.org")
cfgfile = cfgdir / "plomConfig.toml"
def readLastTime() -> dict[str, Any]:
"""Read the login + server options that were used on the last run of the client."""
lastTime: dict[str, Any] = {}
# set some reasonable defaults.
lastTime["LogToFile"] = True # default until stable release?
lastTime["user"] = ""
lastTime["server"] = ""
lastTime["question"] = 1
lastTime["v"] = 1
lastTime["fontSize"] = 10
lastTime["KeyBinding"] = "default"
# update default from config file
if cfgfile.exists():
# too early to log: log.info("Loading config file %s", cfgfile)
with open(cfgfile, "rb") as f:
lastTime.update(tomllib.load(f))
return lastTime
[docs]
class Chooser(QDialog):
def __init__(self, Qapp):
self.APIVersion = Plom_API_Version
super().__init__()
uic.loadUi(resources.files(ui_files) / "chooser.ui", self)
self.Qapp = Qapp
self.messenger = None
self._old_client_note_seen = False
self.lastTime = readLastTime()
kwargs = {}
if self.lastTime.get("LogToFile"):
# filename must not have ":" (forbidden on win32)
# e.g., use "ZZZ" not "ZZ" as the latter has "+00:00"
now = arrow.now().format("YYYY-MM-DD_HH-mm-ss_ZZZ")
logfile = f"plomclient-{now}.log"
try:
logdir.mkdir(parents=True, exist_ok=True)
logfile = logdir / logfile
except PermissionError:
pass
kwargs = {"filename": logfile}
logging.basicConfig(
format="%(asctime)s %(levelname)5s:%(name)s\t%(message)s",
datefmt="%b%d %H:%M:%S %Z",
**kwargs,
)
# Default to INFO log level
logging.getLogger().setLevel(self.lastTime.get("LogLevel", "Info").upper())
s = "Plom Client {} (communicates with api {})".format(
__version__, self.APIVersion
)
log.info(s)
self._workdir = Path(tempfile.mkdtemp(prefix="plom_"))
log.info("Using newly created temp directory %s", self._workdir)
# TODO: with uic, we don't have a .ui: can go through and remove
self.ui = self
self.mportSB.setValue(int(Default_Port))
# Append version to window title
self.setWindowTitle("{} {}".format(self.windowTitle(), __version__))
self.ui.markButton.clicked.connect(self.run_marker)
self.ui.identifyButton.clicked.connect(self.run_identifier)
self.ui.aboutButton.clicked.connect(lambda: show_about_dialog(self))
# Hide button used for directly opening manager
self.ui.manageButton.setVisible(False)
self.ui.manageButton.clicked.connect(self.run_manager)
self.ui.closeButton.clicked.connect(self.close)
self.ui.fontSB.valueChanged.connect(self.setFontSize)
self.ui.optionsButton.clicked.connect(self.options)
self.ui.getServerInfoButton.clicked.connect(self.validate_server)
self.ui.logoutButton.setVisible(False)
self.ui.logoutButton.clicked.connect(self.logout)
# Chooser is a QDialog and has special Enter behaviour
# TODO: but it doesn't work, Issue #3423.
self.ui.loginButton.setDefault(True)
self.ui.loginButton.clicked.connect(self.login)
# clear the validation on server edit
self.ui.serverLE.textEdited.connect(self.ungetInfo)
self.ui.serverLE.setPlaceholderText("plom.example.com")
self.ui.mportSB.valueChanged.connect(self.ungetInfo)
self.ui.vDrop.setVisible(False)
self.ui.pgDrop.setVisible(False)
# TODO: properly with a QValidator? maybe as part of a more general parser
self.ui.serverLE.editingFinished.connect(self.partial_parse_address)
# set login etc from last time client ran.
self.ui.userLE.setText(self.lastTime["user"])
self.setServer(self.lastTime["server"])
self.ui.pgSB.setMinimum(1)
self.ui.vSB.setMinimum(1)
self.ui.pgSB.setValue(int(self.lastTime["question"]))
self.ui.vSB.setValue(int(self.lastTime["v"]))
self.ui.fontSB.setValue(int(self.lastTime["fontSize"]))
# TODO: see Issue #3423, this below workaround doesn't work for me
# def keyPressEvent(self, event):
# if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
# # Only connect to login if the current focus is at password line edit
# # Otherwise the return key works as default.
# if self.focusWidget() == self.ui.passwordLE:
# self.login()
# else:
# super().keyPressEvent(event)
[docs]
def setServer(self, s: str) -> None:
"""Set the server and port UI widgets from a string.
If port is missing, a default will be used. If we cannot
parse the url, just leave it alone.
"""
self.ui.serverLE.setText(s)
self.partial_parse_address()
def options(self) -> None:
d = ClientSettingsDialog(
self, self.lastTime, logdir, cfgfile, tempfile.gettempdir()
)
if d.exec() != QDialog.DialogCode.Accepted:
return
# TODO: do something more proper like QSettings
opt = d.get_options_back()
self.lastTime["FOREGROUND"] = opt["FOREGROUND"]
self.lastTime["LogLevel"] = opt["LogLevel"]
self.lastTime["LogToFile"] = opt["LogToFile"]
logging.getLogger().setLevel(self.lastTime["LogLevel"].upper())
def _launch_subapp(self, which_subapp: str) -> None:
if not self.is_logged_in():
self.login()
if not self.is_logged_in():
return
assert self.messenger is not None
if self.messenger.is_legacy_server() and self.messenger.username == "manager":
if which_subapp != "Manager":
InfoMsg(
self,
"<p>You are not allowed to mark or ID papers while "
"logged-in as “manager”.</p>",
).exec()
return
self.saveDetails()
img_cache_dir = self._workdir / "page_img_cache"
img_cache_dir.mkdir(exist_ok=True)
self.Qapp.downloader = Downloader(img_cache_dir, msgr=self.messenger)
try:
role = self.messenger.get_user_role()
except PlomNoServerSupportException:
role = ""
if which_subapp == "Manager":
if not self.messenger.is_legacy_server():
InfoMsg(
self,
"<p>Only legacy servers have a manager app: "
"how did you get here?</p>",
).exec()
return
if not self.messenger.username == "manager":
InfoMsg(self, 'Only "manager" can manager.').exec()
return
# Importing here avoids a circular import
from plom.manager import Manager
self.setEnabled(False)
self.hide()
window = Manager(self.Qapp, manager_msgr=self.messenger)
window.show()
# store ref in Qapp to avoid garbase collection
self.Qapp._manager_window = window
elif which_subapp == "Marker":
if len(role) and role not in ["marker", "lead_marker"]:
WarnMsg(self, "Only marker/lead marker can mark papers!").exec()
return
question = self.getQuestion()
v = self.getv()
assert question is not None
assert v is not None
self.setEnabled(False)
self.hide()
markerwin = MarkerClient(self.Qapp, tmpdir=self._workdir)
markerwin.my_shutdown_signal.connect(self.on_marker_window_close)
markerwin.show()
markerwin.setup(self.messenger, question, v, self.lastTime)
# store ref in Qapp to avoid garbase collection
self.Qapp.marker = markerwin
elif which_subapp == "Identifier":
if len(role) and role != "lead_marker":
InfoMsg(
self,
"<p>Only lead marker should be identifying papers.</p>"
"<p>You may want to ask your instructor/manager to"
" promote your account. (In the future this might be"
" enforced, but isn't as of Oct 2024.)</p>",
).exec()
# TODO: maybe this should be enforced serverside?
# return
self.setEnabled(False)
self.hide()
idwin = IDClient(self.Qapp, tmpdir=self._workdir)
idwin.my_shutdown_signal.connect(self.on_identify_window_close)
idwin.show()
idwin.setup(self.messenger)
# store ref in Qapp to avoid garbase collection
self.Qapp.identifier = idwin
else:
raise RuntimeError("Invalid subapplication value")
def run_marker(self) -> None:
self._launch_subapp("Marker")
def run_identifier(self) -> None:
self._launch_subapp("Identifier")
def run_manager(self) -> None:
self._launch_subapp("Manager")
[docs]
def saveDetails(self) -> None:
"""Write the options to the config file."""
self.lastTime["user"] = self.ui.userLE.text().strip()
server = self.ui.serverLE.text().strip()
port_txt = self.ui.mportSB.text()
if port_txt:
server += ":" + port_txt
self.lastTime["server"] = server
self.lastTime["question"] = self.getQuestion()
self.lastTime["v"] = self.getv()
self.lastTime["fontSize"] = self.ui.fontSB.value()
log.info("Saving config file %s", cfgfile)
try:
cfgfile.parent.mkdir(exist_ok=True)
with open(cfgfile, "w") as fh:
tomlkit.dump(self.lastTime, fh)
except OSError as e:
WarnMsg(
self,
"Cannot write config file:\n"
" {}\n\n"
"Any settings will not be saved for future sessions.\n\n"
"Error msg: {}.".format(cfgfile, e),
).exec()
[docs]
def closeEvent(self, event: None | QtGui.QCloseEvent) -> None:
self.saveDetails()
dl = getattr(self.Qapp, "downloader", None)
if dl:
while True:
if dl.stop(1):
# basedir should be empty; sometimes it isn't (Issue #3644)
# dl.basedir.rmdir()
shutil.rmtree(dl.basedir)
break
log.warning("still waiting for download to stop...")
time.sleep(0.25)
log.info("Wiping temp directory %s", self._workdir)
shutil.rmtree(self._workdir)
self.logout()
[docs]
def setFontSize(self, n: int) -> None:
"""Adjust font size of user interface.
Args:
n: the desired font size in points.
"""
fnt = self.Qapp.font()
fnt.setPointSize(n)
self.Qapp.setFont(fnt)
[docs]
def getQuestion(self) -> int | None:
"""Return the integer question or None."""
if self.ui.pgDrop.isVisible():
question = self.ui.pgDrop.currentIndex() + 1
else:
question = self.ui.pgSB.value()
try:
return int(question)
except ValueError:
return None
[docs]
def getv(self) -> int | None:
"""Return the integer version or None."""
if self.ui.vDrop.isVisible():
v = self.ui.vDrop.currentText()
else:
v = self.ui.vSB.value()
try:
return int(v)
except: # noqa: E722
return None
def ungetInfo(self) -> None:
self.ui.markGBox.setTitle("Choose a task")
question = self.getQuestion()
v = self.getv()
self.ui.pgSB.setVisible(True)
self.ui.vSB.setVisible(True)
if question:
self.ui.pgSB.setValue(question)
if v:
self.ui.vSB.setValue(v)
self.ui.vDrop.clear()
self.ui.vDrop.setVisible(False)
self.ui.pgDrop.clear()
self.ui.pgDrop.setVisible(False)
self.ui.infoLabel.setText("")
self.logout()
def _pre_login_connection(self, msgr: Messenger | ManagerMessenger) -> bool:
# This msgr object may or may not be logged in: it can be temporary: we
# only use it to get public info from the server.
#
# Side effects:
# The `msgr` itself will be modified, e.g., if user excepted
# SSL verification. It also figures out if we're talking to
# a legacy or new server (and stores that info).
_ssl_excused = False
try:
try:
server_ver_str = msgr._start(interactive=True)
except PlomSSLError as e:
msg = WarningQuestion(
self,
"SSL error: cannot verify the identity of the server.",
"Do you want to disable SSL certificate verification? Not recommended.",
details=f"{e}",
)
msg.setDefaultButton(QMessageBox.StandardButton.No)
if msg.exec() == QMessageBox.StandardButton.No:
return False
_ssl_excused = True
msgr.force_ssl_unverified()
server_ver_str = msgr._start(interactive=True)
except PlomBenignException as e:
WarnMsg(
self,
f"<p>Could not connect to server “{msgr.server}”.<br />"
"Check the server address and your internet connection?</p>"
" <p>The precise error message was:</p>",
info=f"{e}",
info_pre=False,
).exec()
return False
self.ui.infoLabel.setText(server_ver_str)
if _ssl_excused:
s = "\nCaution: SSL exception granted."
self.ui.infoLabel.setText(self.ui.infoLabel.text() + s)
# old servers (<0.14.0) don't have this API and will fail
info = msgr.get_server_info()
if "Legacy" in info["product_string"]:
s = "\nUsing legacy messenger"
self.ui.infoLabel.setText(self.ui.infoLabel.text() + s)
try:
msgr._set_server_API_version(info["API_version"])
except PlomAPIException as e:
WarnMsg(
self,
f"Your client version {__version__} cannot connect to "
f"unsupported server {info['version']}.",
info=f"{e}",
).exec()
return False
if Version(__version__) < Version(info["version"]):
s = "\nWARNING: old client!"
self.ui.infoLabel.setText(self.ui.infoLabel.text() + s)
msg_ = WarnMsg(
self,
f"Your client version {__version__} is older than the "
f"server {info['version']}: you may want to consider upgrading.",
details=(
f"You have Plom Client {__version__} with API {self.APIVersion}\n"
f"Server: {info['product_string']}\n"
f"Server version: {info['version']}"
),
)
if not self._old_client_note_seen:
msg_.exec()
self._old_client_note_seen = True
return True
def validate_server(self) -> None:
self.start_messenger_get_info()
if not self.messenger:
return
# if successful, put focus at username or password line-edit
if len(self.ui.userLE.text()) > 0:
self.ui.passwordLE.setFocus()
else:
self.ui.userLE.setFocus()
[docs]
def start_messenger_get_info(
self, *, _legacy_username: str | None = None, verify_ssl: bool = True
) -> None:
"""Get info from server, update UI with server version, check SSL.
Keyword Args:
_legacy_username: normally we don't care who might eventually
login, except in the legacy case and if it might be the
manager.
verify_ssl: True by default but if False then don't pop up
dialogs about lacking SSL verification. Should not be
used lightly! Currently we let users make this decision
one login at a time.
Returns:
None, but modifies the state of the internal `messenger`
instance variable. If that is not None, you can assume
something reasonable happened.
"""
server = self.ui.serverLE.text().strip()
if not server:
self.ui.infoLabel.setText("You must specify a server address")
return
# due to special handling of blank versus default, use .text() not .value()
port = self.ui.mportSB.text()
# TODO: might be nice, but needs another thread?
# self.ui.infoLabel.setText("connecting...")
# self.ui.infoLabel.repaint()
if self.is_logged_in():
self.logout()
if self.messenger:
self.messenger.stop()
self.messenger = None
try:
msgr: Messenger | ManagerMessenger = Messenger(
server, port=port, verify_ssl=verify_ssl
)
if not self._pre_login_connection(msgr):
return
except PlomException as e:
WarnMsg(self, "Could not connect to server", info=str(e)).exec()
return
if msgr.is_legacy_server():
if _legacy_username and _legacy_username == "manager":
verified = msgr.is_ssl_verified()
msgr.stop()
msgr = ManagerMessenger(server, port=port, verify_ssl=verified)
if not self._pre_login_connection(msgr):
return
# Once we're happy with the manager keep it, b/c it knows if we
# have made an SSL exception for example.
self.messenger = msgr
def is_logged_in(self) -> bool:
if not self.messenger:
return False
if self.messenger.token:
return True
return False
[docs]
def logout(self) -> None:
"""Logout if not already logged out.
Its safe to call this if you're not logged in, don't have a messenger etc.
"""
if not self.messenger:
return
try:
self.messenger.closeUser()
except PlomAuthenticationException as e:
log.info(f"Authentication error during logout: {e}")
pass
self.messenger.stop()
self.messenger = None
self.ui.loginInfoLabel.setText("logged out")
self._old_client_note_seen = False
self.ui.manageButton.setVisible(False)
self.ui.logoutButton.setVisible(False)
self.ui.userLE.setEnabled(True)
self.ui.passwordLE.setEnabled(True)
self.ui.serverLE.setEnabled(True)
self.ui.mportSB.setEnabled(True)
self.ui.loginButton.setEnabled(True)
[docs]
def login(self) -> None:
"""Login to the server but don't start any tasks yet.
Also update the UI with restricted questions and versions.
"""
user = self.ui.userLE.text().strip()
self.ui.userLE.setText(user)
if not user:
return
pwd = self.ui.passwordLE.text()
if not pwd:
return
if self.is_logged_in():
self.logout()
verified = True
# Legacy special cases if we already have the wrong type of messenger,
# e.g., someone changed the username after validating but before login.
if self.messenger and self.messenger.is_legacy_server():
if user == "manager" and not isinstance(self.messenger, ManagerMessenger):
verified = self.messenger.is_ssl_verified()
self.logout()
elif user != "manager" and isinstance(self.messenger, ManagerMessenger):
verified = self.messenger.is_ssl_verified()
self.logout()
if not self.messenger:
self.start_messenger_get_info(_legacy_username=user, verify_ssl=verified)
if not self.messenger:
return
try:
self.messenger.requestAndSaveToken(user, pwd)
except PlomAPIException as e:
WarnMsg(
self,
"Could not authenticate due to API mismatch.",
info=f"Client version is {__version__}. {e}",
info_pre=False,
).exec()
self.messenger = None
return
except PlomAuthenticationException as e:
InfoMsg(self, f"Could not authenticate: {e}").exec()
self.messenger = None
return
except PlomExistingLoginException:
msg = WarningQuestion(
self,
"You appear to be already logged in!\n\n"
" * Perhaps a previous session crashed?\n"
" * Do you have another client running,\n"
" e.g., on another computer?\n\n"
"Should I force-logout the existing authorisation?"
" (and then you can try to log in again)\n\n"
"The other client will likely crash.",
)
if msg.exec() == QMessageBox.StandardButton.Yes:
self.messenger.clearAuthorisation(user, pwd)
# harmless probably useless pause, in case Issue #2328 was real
time.sleep(0.25)
# try again
self.login()
return
self.messenger = None
return
except PlomSeriousException as e:
ErrorMsg(
self,
f"Could not get authentication token.\n\nUnexpected error: {str(e)}",
).exec()
self.messenger = None
return
try:
spec = self.messenger.get_spec()
except PlomServerNotReady as e:
if not self.messenger.username == "manager":
WarnMsg(
self,
"Server does not yet have a spec, nothing to mark. "
" Perhaps you want to login with the manager account to"
" configure the server.",
info=str(e),
).exec()
self.messenger = None
return
spec = None
except PlomException as e:
WarnMsg(self, "Could not connect to server", info=str(e)).exec()
self.messenger = None
return
if spec:
self._set_restrictions_from_spec(spec)
self.ui.loginInfoLabel.setText(f'logged in as "{user}"')
self.ui.logoutButton.setVisible(True)
self.ui.userLE.setEnabled(False)
self.ui.passwordLE.setEnabled(False)
self.ui.serverLE.setEnabled(False)
self.ui.mportSB.setEnabled(False)
self.ui.loginButton.setEnabled(False)
if self.messenger.is_legacy_server() and self.messenger.username == "manager":
self.ui.manageButton.setVisible(True)
def _set_restrictions_from_spec(self, spec: dict[str, Any]) -> None:
self.ui.markGBox.setTitle("Choose a task for “{}”".format(spec["name"]))
question = self.getQuestion()
v = self.getv()
self.ui.pgSB.setVisible(False)
self.ui.vSB.setVisible(False)
self.ui.vDrop.clear()
self.ui.vDrop.addItems([str(x + 1) for x in range(0, spec["numberOfVersions"])])
if v:
if v >= 1 and v <= spec["numberOfVersions"]:
self.ui.vDrop.setCurrentIndex(v - 1)
self.ui.vDrop.setVisible(True)
self.ui.pgDrop.clear()
self.ui.pgDrop.addItems(
[get_question_label(spec, n + 1) for n in range(spec["numberOfQuestions"])]
)
if question:
if question >= 1 and question <= spec["numberOfQuestions"]:
self.ui.pgDrop.setCurrentIndex(question - 1)
self.ui.pgDrop.setVisible(True)
# TODO should we also let people type in?
self.ui.pgDrop.setEditable(False)
self.ui.vDrop.setEditable(False)
def _partial_parse_address_manual(self) -> None:
address = self.ui.serverLE.text()
try:
_addr, _port = address.split(":")
except ValueError:
return
if _port == "":
# special case handles "foo:"
self.ui.serverLE.setText(_addr)
return
# this special case handles "foo:1234"
try:
_port = int(_port)
except ValueError:
# special case for stuff with path "foo:1234/user"
self.ui.mportSB.clear()
return
self.ui.mportSB.setValue(_port)
self.ui.serverLE.setText(_addr)
[docs]
def partial_parse_address(self) -> None:
"""If address has a port number in it, extract and move to the port box.
If there's a colon in the address (maybe user did not see port
entry box or is pasting in a string), then try to extract a port
number and put it into the entry box.
In some rare cases, we actively clear the port box, for example
when the URL seems to have a path.
"""
address = self.ui.serverLE.text()
self.ui.mportSB.setEnabled(True)
try:
parsedurl = urllib3.util.parse_url(address)
if not parsedurl.host:
self._partial_parse_address_manual()
return
if parsedurl.scheme and parsedurl.scheme.casefold() != "https":
# special case non-https uri
self.ui.mportSB.clear()
self.ui.mportSB.setEnabled(False)
return
if parsedurl.path:
# don't muck with things like "localhost:1234/base/url"
# activitely remove our port setting from such things
self.ui.mportSB.clear()
self.ui.mportSB.setEnabled(False)
return
if parsedurl.port:
self.ui.mportSB.setValue(int(parsedurl.port))
self.ui.serverLE.setText(parsedurl.host)
except urllib3.exceptions.LocationParseError:
return
@pyqtSlot(int)
def on_identify_window_close(self, value: int) -> None:
# `value` is always 1, no real meaning yet
self.show()
self.setEnabled(True)
# TODO: wall-paper for Issue #2903
if not self.is_logged_in():
self.logout()
@pyqtSlot(int, list)
def on_marker_window_close(self, value: int, stuff: list[Any] | None) -> None:
# `value` is always 2, no real meaning yet
self.show()
self.setEnabled(True)
# TODO: wall-paper for Issue #2903
if not self.is_logged_in():
self.logout()
if not stuff:
return
# note `stuff` is list of options - used to contain more... may contain more in future
# TODO: don't save custom until Issue #2254
if stuff[0] != "custom":
self.lastTime["KeyBinding"] = stuff[0]
# TODO: not writing to disc until Issue #2254
# self.lastTime["CustomKeys"] = stuff[1]