from copy import copy
from dataclasses import dataclass, field, fields, is_dataclass
from datetime import datetime, timezone
from typing import Any, Mapping, NewType
import marshmallow
from marshmallow import missing, pre_load
from tim_common.utils import Missing
[docs]@dataclass
class PointsRule:
maxPoints: str | int | float | None | Missing = missing
allowUserMin: int | float | None | Missing = missing
allowUserMax: int | float | None | Missing = missing
multiplier: int | float | None | Missing = missing
penalties: dict[str, float] | None | Missing = missing
[docs]class PluginDateTimeField(marshmallow.fields.Field):
def _serialize(
self, value: Any, attr: str, obj: Any, **kwargs: dict[str, Any]
) -> Any:
raise NotImplementedError
def _deserialize(
self,
value: Any,
attr: str | None,
data: Mapping[str, Any] | None,
**kwargs: dict[str, Any],
) -> datetime:
d = None
if isinstance(value, datetime):
d = value
elif isinstance(value, str):
try:
d = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
except ValueError:
raise self.make_error("validator_failed")
if d:
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
return d
raise self.make_error("validator_failed")
PluginDateTime = NewType("PluginDateTime", datetime)
PluginDateTime._marshmallow_field = PluginDateTimeField # type: ignore
[docs]class HiddenFieldsMixin:
[docs] @pre_load
def process_minus(self, data: Any, **_: dict[str, Any]) -> Any:
if isinstance(data, dict):
data = copy(data) # Don't modify the original.
hidden_keys = {
k[1:] for k in data.keys() if isinstance(k, str) and k.startswith("-")
}
for k in hidden_keys:
data[k] = data.pop(f"-{k}")
data["hidden_keys"] = hidden_keys
return data
[docs]@dataclass
class KnownMarkupFields(HiddenFieldsMixin):
"""Represents the plugin markup fields that are known and used by TIM."""
accessDuration: int | None | Missing = missing
accessEndText: str | None | Missing = missing
anonymous: bool | None | Missing = missing
answerLimit: int | None | Missing = missing
automd: bool | None | Missing = missing
buttonNewTask: str | None | Missing = missing
cache: bool | None | Missing = missing
deadline: PluginDateTime | datetime | None | Missing = missing
fields: list[str] | None | Missing = missing
floatHeader: str | None | Missing = missing
floatSize: tuple[int, int] | None | Missing = missing
header: str | None | Missing = missing
headerText: str | None | Missing = missing
hideBrowser: bool | Missing | None = missing
initNewAnswer: str | None | Missing = missing
lazy: bool | Missing = missing
maxHeight: str | None | Missing = field(
metadata={"data_key": "max-height"}, default=missing
)
minHeight: str | None | Missing = field(
metadata={"data_key": "min-height"}, default=missing
)
pointsRule: PointsRule | None | Missing = missing
pointsText: str | None | Missing = missing
postprogram: str | Missing = missing
postoutput: str | Missing = missing
showPoints: bool | None | Missing = missing
starttime: PluginDateTime | datetime | None | Missing = missing
showInView: bool | Missing = missing
stem: str | None | Missing = missing
triesText: str | None | Missing = missing
useCurrentUser: bool | None | Missing = missing
texafterprint: str | None | Missing = missing
texbeforeprint: str | None | Missing = missing
texprint: str | None | Missing = missing
[docs] def show_points(self) -> bool:
if isinstance(self.showPoints, bool):
return self.showPoints
return True
[docs] def tries_text(self) -> str:
if isinstance(self.triesText, str):
return self.triesText
return "Tries left:"
[docs] def points_text(self) -> str:
if isinstance(self.pointsText, str):
return self.pointsText
return "Points:"
[docs]def asdict_skip_missing(obj: Any) -> dict[str, Any]:
result = []
for f in fields(obj):
v = getattr(obj, f.name)
if v is missing:
continue
if f.metadata.get("missing", False):
continue
value = asdict_skip_missing(v) if is_dataclass(v) else v
result.append((f.name, value))
return dict(result)
[docs]def list_not_missing_fields(inst: Any) -> list:
return list(((k, v) for k, v in asdict_skip_missing(inst).items()))
[docs]@dataclass
class UndoInfo:
button: str | None | Missing = missing
title: str | None | Missing = missing
confirmation: str | None | Missing = missing
confirmationTitle: str | None | Missing = missing
[docs]@dataclass
class GenericMarkupModel(KnownMarkupFields):
"""Specifies which fields the editor can use in the plugin markup.
This base class defines some fields that are applicable to all plugins.
The difference to KnownMarkupFields is that this class should define fields that are not used by TIM.
TODO: Some fields here should be moved to KnownMarkupFields.
"""
hidden_keys: list[str] | Missing = missing
"""Meta field that keeps track which markup fields were hidden (that is, prefixed with "-").
Hidden keys are never sent to browser.
"""
button: str | None | Missing = missing
buttonText: str | None | Missing = missing
allowUnsavedLeave: bool | Missing | None = missing
disableUnchanged: bool | Missing | None = missing
footer: str | Missing = missing
forceBrowser: bool | Missing | None = missing
globalField: bool | Missing | None = missing
readonly: bool | Missing | None = missing
lang: str | None | Missing = missing
resetText: str | Missing | None = missing
connectionErrorMessage: str | Missing = missing
undo: UndoInfo | Missing | None = missing
[docs] def get_visible_data(self) -> dict:
assert isinstance(self.hidden_keys, list)
return {
k: v
for k, v in list_not_missing_fields(self)
if k not in self.hidden_keys and k != "hidden_keys"
}