# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2021-2024 Colin B. Macdonald
"""Tools for upload/downloading rubrics from Plom servers."""
import json
import sys
if sys.version_info >= (3, 9):
from importlib import resources
else:
import importlib_resources as resources
if sys.version_info < (3, 11):
import tomli as tomllib
else:
import tomllib
import tomlkit
# try to avoid importing Pandas unless we use specific functions: Issue #2154
# import pandas
from plom.create import with_manager_messenger
[docs]@with_manager_messenger
def download_rubrics(*, msgr):
"""Download a list of rubrics from a server.
Keyword Args:
msgr (plom.Messenger/tuple): either a connected Messenger or a
tuple appropriate for credientials.
Returns:
list: list of dicts, possibly an empty list if server has no rubrics.
"""
return msgr.MgetRubrics()
[docs]@with_manager_messenger
def download_rubrics_to_file(filename, *, msgr, verbose: bool = True) -> None:
"""Download the rubrics from a server and save them to a file.
Args:
filename (pathlib.Path): A filename to save to. The extension is
used to determine what format, supporting:
`.json`, `.toml`, and `.csv`.
If no extension is included, default to `.toml`.
Keyword Args:
msgr (plom.Messenger/tuple): either a connected Messenger or a
tuple appropriate for credientials.
verbose (bool): display diagnostic output on stdout.
Returns:
None: but saves a file as a side effect.
"""
if filename.suffix.casefold() not in (".json", ".toml", ".csv"):
filename = filename.with_suffix(filename.suffix + ".toml")
suffix = filename.suffix
if verbose:
print(f'Saving server\'s current rubrics to "{filename}"')
rubrics = download_rubrics(msgr=msgr)
with open(filename, "w") as f:
if suffix == ".json":
json.dump(rubrics, f, indent=" ")
elif suffix == ".toml":
tomlkit.dump({"rubric": rubrics}, f)
elif suffix == ".csv":
import pandas
df = pandas.json_normalize(rubrics)
df.to_csv(f, index=False, sep=",", encoding="utf-8")
else:
raise NotImplementedError(f'Don\'t know how to export to "{filename}"')
[docs]def upload_rubrics_from_file(filename, *, msgr, verbose: bool = True) -> None:
"""Load rubrics from a file and upload them to a server.
Args:
filename (pathlib.Path): A filename to load from. Types `.json`,
`.toml`, and `.csv` are supported. If no suffix is included
we'll try to append `.toml`.
Keyword Args:
msgr (plom.Messenger/tuple): either a connected Messenger or a
tuple appropriate for credientials.
verbose (bool): display diagnostic output on stdout.
Returns:
None
"""
if filename.suffix.casefold() not in (".json", ".toml", ".csv"):
filename = filename.with_suffix(filename.suffix + ".toml")
suffix = filename.suffix
if suffix == ".json":
with open(filename, "r") as f:
rubrics = json.load(f)
elif suffix == ".toml":
with open(filename, "rb") as f:
rubrics = tomllib.load(f)["rubric"]
elif suffix == ".csv":
with open(filename, "r") as f:
import pandas
df = pandas.read_csv(f)
df.fillna("", inplace=True)
# TODO: flycheck is whining about this to_json
rubrics = json.loads(df.to_json(orient="records"))
else:
raise NotImplementedError(f'Don\'t know how to import from "{filename}"')
if verbose:
print(f'Adding {len(rubrics)} rubrics from file "{filename}"')
upload_rubrics(rubrics, msgr=msgr)
[docs]@with_manager_messenger
def upload_rubrics(rubrics, *, msgr) -> None:
"""Upload a list of rubrics to a server."""
for rub in rubrics:
# Generally we don't want to upload autogenerated rubrics: this HAL
# test is not quite right as fact some autogenerated deltas and
# "no marks", "full marks" are made by manager... Issue #1494.
if rub.get("username", None) == "HAL":
continue
# But we get around the above by uploading only relative and neutral
# rubrics. No absolute or delta rubrics (as those are currently
# autogenerated). The exact logic may need tweaking in the future!
if rub["kind"] not in ("neutral", "relative"):
continue
msgr.McreateRubric(rub)
[docs]@with_manager_messenger
def upload_demo_rubrics(*, msgr, numquestions: int = 3) -> int:
"""Load some demo rubrics and upload to server.
Keyword Args:
msgr (plom.Messenger/tuple): either a connected Messenger or a
tuple appropriate for credientials.
numquestions (int): how many questions should we build for.
TODO: get number of questions from the server spec if
omitted.
Returns:
How many rubrics were created.
The demo data is a bit sparse: we fill in missing pieces and
multiply over questions.
"""
with open(resources.files("plom") / "demo_rubrics.toml", "rb") as f:
_rubrics_in = tomllib.load(f)
rubrics_in = _rubrics_in["rubric"]
rubrics = []
for rub in rubrics_in:
if not rub.get("kind"):
if rub["delta"] == ".":
rub["kind"] = "neutral"
rub["value"] = 0
rub["out_of"] = 0
elif rub["delta"].startswith("+") or rub["delta"].startswith("-"):
rub["kind"] = "relative"
rub["value"] = int(rub["delta"])
rub["out_of"] = 0 # unused for relative
else:
raise ValueError(f'not sure how to map "kind" for rubric:\n {rub}')
rub["display_delta"] = rub["delta"]
rub.pop("delta")
# Multiply rubrics w/o question numbers, avoids repetition in demo file
if rub.get("question") is None:
for q in range(1, numquestions + 1):
r = rub.copy()
r["question"] = q
rubrics.append(r)
else:
rubrics.append(rub)
upload_rubrics(rubrics, msgr=msgr)
return len(rubrics)