Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 115 additions & 10 deletions bases/rsptx/admin_server_api/routers/lti1p3.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@
from rsptx.lti1p3.pylti1p3.exception import LtiException, LtiServiceException
from rsptx.lti1p3.pylti1p3.deep_link import DeepLinkResource


# Routing
# =======
router = APIRouter(
Expand Down Expand Up @@ -147,6 +146,44 @@ def get_session_service():
return FastAPISessionService(RedisCache())


def parse_lti_datetime_as_utc(
datetime_string: Optional[str],
) -> Optional[datetime.datetime]:
"""
Parse an LTI ISO datetime and return a naive UTC datetime for storage.
Unresolved LTI substitution values indicate that the LMS has a null (but knows about the variable).
An unresolved parameter string indicates that the LMS does not recognize the variable. That will become an exception.
"""
if not datetime_string or datetime_string == "":
return None

normalized_datetime_string = (
datetime_string.replace("Z", "+00:00")
if datetime_string.endswith("Z")
else datetime_string
)
lti_datetime = datetime.datetime.fromisoformat(normalized_datetime_string)
if lti_datetime.tzinfo is None:
return lti_datetime
return lti_datetime.astimezone(datetime.timezone.utc).replace(tzinfo=None)


def format_lti_datetime_as_utc(
datetime_value: Optional[datetime.datetime],
) -> Optional[str]:
"""
Format a naive UTC datetime for LTI as an explicit UTC ISO datetime.
"""
if datetime_value is None:
return None

if datetime_value.tzinfo is None:
datetime_value = datetime_value.replace(tzinfo=datetime.timezone.utc)
else:
datetime_value = datetime_value.astimezone(datetime.timezone.utc)
return datetime_value.isoformat().replace("+00:00", "Z")


async def login_or_create_user(
launch: FastAPIMessageLaunch, lti_course: Lti1p3Course, course: CoursesValidator
) -> tuple[Lti1p3User, str]:
Expand Down Expand Up @@ -353,8 +390,6 @@ async def launch(request: Request):
key, value = p.split("=")
query_params[key] = value

rslogger.debug(f"LTI1p3 - launch params: {query_params}")

# Start by identifying the kind of launch this is. Will need different info from different kinds of launches
# Type 1: Book links - links to specific pages in the book
# They will have a query param "book_page"
Expand Down Expand Up @@ -470,9 +505,18 @@ async def launch(request: Request):
await update_lti_assignment_record(assign_lineitem, lti_course, rs_assign)

# make sure RS assignment is up to date (e.g. end date)
lti_config = await fetch_lti1p3_config_by_lti_data(
message_launch.get_iss(), message_launch.get_client_id()
)
course_attributes = await fetch_all_course_attributes(course.id)
custom_params = message_launch.get_custom_params()
await update_rsassignment_from_lti(
rs_assign, assign_lineitem, course_attributes, course
rs_assign,
assign_lineitem,
course_attributes,
course,
custom_params,
lti_config.product_family_code,
)

# start redirect to assignment
Expand Down Expand Up @@ -523,10 +567,17 @@ async def update_rsassignment_from_lti(
line_item: LineItem,
course_attributes: dict,
course: Courses,
custom_params: Optional[dict] = None,
product_family_code: Optional[str] = None,
) -> AssignmentValidator:
"""
Update a runestone assignment from LTI data.
"""
if course_attributes.get("ignore_lti_dates") == "true":
return assign

updated = False

try:
lms_due_string = line_item.get_end_date_time()
rslogger.info(
Expand All @@ -551,16 +602,60 @@ async def update_rsassignment_from_lti(
lms_due = lms_due.replace(tzinfo=tz)
lms_due = lms_due.astimezone(datetime.timezone.utc).replace(tzinfo=None)
rslogger.info(f"LTI1p3 - Storing {lms_due} UTC for assignment {assign.name}")
if (
lms_due is not None
and lms_due != assign.duedate
and course_attributes.get("ignore_lti_dates") != "true"
):
if lms_due is not None and lms_due != assign.duedate:
assign.duedate = lms_due
await update_assignment(assign)
updated = True
except Exception:
# just ignore bad dates, could be missing, bad format, etc
pass

custom_params = custom_params or {}
availability_datetime_params = (
("visible_on", "resource_link_available_startdatetime"),
("hidden_on", "resource_link_available_enddatetime"),
)
for assignment_field, custom_param in availability_datetime_params:
raw_value = custom_params.get(custom_param)
if raw_value is None:
continue

# If the LMS does not recognize the variable, it will be returned verbatim.
# Otherwise, it should be a valid ISO datetime string or empty string.
# Empty indicates a meaningful null value.
try:
availability_datetime = parse_lti_datetime_as_utc(raw_value)
except Exception:
# Bad datetime or the verbatim param string.
# For most LMS's that means we should ignore. But Canvas does know
# about these variables and returns an unresolved variable
# when there is a null value.
if (
product_family_code == "canvas"
and isinstance(raw_value, str)
and raw_value.startswith("$ResourceLink.available.")
):
availability_datetime = None
else:
# just ignore bad dates, could be missing, bad format, etc
continue

if availability_datetime != getattr(assign, assignment_field):
setattr(assign, assignment_field, availability_datetime)
updated = True

# check for a custom parameter that indicates the assignment is published in Canvas
canvas_assignment_published = custom_params.get("canvas_assignment_published")
if (
canvas_assignment_published is not None
and str(canvas_assignment_published).lower() == "true"
):
if not assign.visible:
assign.visible = True
updated = True

if updated:
await update_assignment(assign)

return assign


Expand Down Expand Up @@ -682,6 +777,10 @@ async def register_with_platform(platform_config: dict, token: str = None) -> di
"custom_parameters": {
"context_id_history": "$Context.id.history",
"resource_link_history": "$ResourceLink.id.history",
"resource_link_submission_enddatetime": "$ResourceLink.submission.endDateTime",
"resource_link_available_startdatetime": "$ResourceLink.available.startDateTime",
"resource_link_available_enddatetime": "$ResourceLink.available.endDateTime",
"canvas_assignment_published": "$Canvas.assignment.published",
},
"claims": [
"sub",
Expand Down Expand Up @@ -1134,6 +1233,12 @@ async def assign_select(launch_id: str, request: Request, course=None):
dlr.set_url(launch_url)
dlr.set_title(assign.name)
dlr.set_target("window")
available_start_datetime = format_lti_datetime_as_utc(assign.visible_on)
if available_start_datetime:
dlr.set_available_start_date_time(available_start_datetime)
available_end_datetime = format_lti_datetime_as_utc(assign.hidden_on)
if available_end_datetime:
dlr.set_available_end_date_time(available_end_datetime)

line_item = LineItem()
update_line_item_from_assignment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ describe("getVisibilityMode", () => {
expect(getVisibilityMode(undefined, null, null)).toBe("hidden");
});

it("prioritizes scheduled_hidden over plain visible when both visible and hidden_on set", () => {
it("prefers scheduled_period when both visible_on and hidden_on are set, even if visible", () => {
expect(getVisibilityMode(true, "2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")).toBe(
"scheduled_hidden"
"scheduled_period"
);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export const getVisibilityMode = (
visibleOn: string | null | undefined,
hiddenOn: string | null | undefined
): VisibilityMode => {
if (visibleOn && hiddenOn) {
return "scheduled_period";
}
if (!visible) {
if (visibleOn && hiddenOn) {
return "scheduled_period";
}
if (visibleOn) {
return "scheduled_visible";
}
Expand Down
2 changes: 1 addition & 1 deletion components/rsptx/auth/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def cookie_domain(self) -> Optional[str]:
return settings.load_balancer_host or None

def set_cookie(self, response, token):
production = settings.server_config == "production"
production = settings.server_protocol.startswith("https://")
domain = self.cookie_domain
if domain:
# Anyone who logged in before the cookie was scoped still has a
Expand Down
15 changes: 15 additions & 0 deletions components/rsptx/configuration/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@ def server_url(self) -> str:
scheme = "https" if self.certbot_email or self.caddy_site_address else "http"
return f"{scheme}://{self.runestone_host}"

@property
def server_protocol(self) -> str:
"""Return the scheme (protocol) for this deployment.

Similar to above but only stores the scheme.

:return: The scheme, e.g. ``https://``.
:rtype: str
"""
if self.load_balancer_host:
return "https://"
if self.caddy_site_address:
return "https://"
return "https://" if self.certbot_email else "http://"

# Configure ads. TODO: Link to the place in the Runestone Components where this is used.
adsenseid: str = ""
num_banners: int = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ def __init__(
self._cookies = request_obj.cookies if cookies is None else cookies
self._session = request_obj.session if session is None else session

is_https = request_obj.url.scheme.lower() == "https"
is_https = (
request_obj.url.scheme.lower() == "https"
or "https" in request_obj.headers.get("x-forwarded-proto", "").lower()
)
self._request_is_secure = (
is_https if request_is_secure is None else request_is_secure
)
Expand Down
27 changes: 24 additions & 3 deletions components/rsptx/lti1p3/pylti1p3/deep_link_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class DeepLinkResource:
_custom_params: t.Mapping[str, str] = None
_target: str = "window"
_icon_url: t.Optional[str] = None
_available_start_date_time: t.Optional[str] = None
_available_end_date_time: t.Optional[str] = None

def get_type(self):
return self._type
Expand Down Expand Up @@ -53,6 +55,20 @@ def set_target(self, value: str) -> "DeepLinkResource":
self._target = value
return self

def get_available_start_date_time(self) -> t.Optional[str]:
return self._available_start_date_time

def set_available_start_date_time(self, value: str) -> "DeepLinkResource":
self._available_start_date_time = value
return self

def get_available_end_date_time(self) -> t.Optional[str]:
return self._available_end_date_time

def set_available_end_date_time(self, value: str) -> "DeepLinkResource":
self._available_end_date_time = value
return self

def get_icon_url(self) -> t.Optional[str]:
return self._icon_url

Expand All @@ -74,6 +90,14 @@ def to_dict(self) -> t.Dict[str, object]:
if self._target == "window":
res["window"] = {"targetName": "_runestone"}

available: t.Dict[str, object] = {}
if self._available_start_date_time:
available["startDateTime"] = self._available_start_date_time
if self._available_end_date_time:
available["endDateTime"] = self._available_end_date_time
if available:
res["available"] = available

if self._lineitem:
line_item: t.Dict[str, object] = {
"scoreMaximum": self._lineitem.get_score_maximum(),
Expand All @@ -95,11 +119,8 @@ def to_dict(self) -> t.Dict[str, object]:
if submission_review:
line_item["submissionReview"] = submission_review

# if line item has a end date, include it in the resource
# as both availability and submission end dates
end_date_time = self._lineitem.get_end_date_time()
if end_date_time:
res["available"] = {"endDateTime": end_date_time}
res["submission"] = {"endDateTime": end_date_time}

res["lineItem"] = line_item
Expand Down
60 changes: 59 additions & 1 deletion components/rsptx/lti1p3/pylti1p3/message_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
from .service_connector import ServiceConnector, REQUESTS_USER_AGENT
from .tool_config import ToolConfAbstract


TResourceLinkClaim = te.TypedDict(
"TResourceLinkClaim",
{
Expand Down Expand Up @@ -177,6 +176,9 @@
total=False,
)


CUSTOM_CLAIM = "https://purl.imsglobal.org/spec/lti/claim/custom"

REQ = t.TypeVar("REQ", bound=Request)
TCONF = t.TypeVar("TCONF", bound=ToolConfAbstract)
SES = t.TypeVar("SES", bound=SessionService)
Expand Down Expand Up @@ -462,6 +464,62 @@ def has_ags(self) -> bool:
is not None
)

def has_custom_params(self) -> bool:
"""
Returns whether or not the current launch contains LTI custom parameters.

:return: bool
"""
custom_params = self._get_jwt_body().get(CUSTOM_CLAIM, None)
return isinstance(custom_params, dict) and len(custom_params) > 0

def get_custom_params(self) -> t.Mapping[str, str]:
"""
Fetch custom parameters from the launch payload.

:return: Mapping[str, str]
"""
custom_params = self._get_jwt_body().get(CUSTOM_CLAIM, {})
if custom_params is None:
return {}
if not isinstance(custom_params, dict):
raise LtiException("custom claim must be an object")
return t.cast(t.Mapping[str, str], custom_params)

def has_custom_param(self, key: str) -> bool:
"""
Returns whether a named custom parameter exists in the launch payload.

:param key: Custom parameter key.
:return: bool
"""
return key in self.get_custom_params()

def get_custom_param(
self, key: str, default_value: t.Optional[str] = None
) -> t.Optional[str]:
"""
Fetch a single custom parameter from the launch payload.

:param key: Custom parameter key.
:param default_value: Value to return when key is missing.
:return: str | None
"""
return self.get_custom_params().get(key, default_value)

def require_custom_param(self, key: str) -> str:
"""
Fetch a required custom parameter and fail if it is missing.

:param key: Custom parameter key.
:return: str
:raises LtiException: If key is missing from custom params.
"""
value = self.get_custom_param(key)
if value is None:
raise LtiException(f"Missing custom launch param '{key}'")
return value

def get_dls(self) -> TDeepLinkData:
"""
Fetches deep linking settings for the current launch.
Expand Down
Loading
Loading