Skip to content

Commit dc6e8ab

Browse files
authored
feat(lti): Sync courses with Canvas Connect (#1383)
Resolves #1313 ## Canvas Connect ### New Features - PingPong group user lists can now be synced with Canvas Connect rosters on a set schedule or manually. - As with manual LTI launches from the course navigation, Canvas Connect roster syncs fetch available SSO information, as determined during the tool registration, along with the stable LTI user IDs. - The required `resource_link_id` is saved when a user launches PingPong from Canvas, or is retrieved from the first page of the Names and Role API response as a fallback. The fallback works because all Canvas course navigation placements share the same `context_id` as the `resource_link_id`. See [this Instructure Community discussion](https://community.instructure.com/en/discussion/419857/lti-1-3-same-resource-link-for-all-course-item-instances) for more details. ### Resolved Issues - Fixed: Canvas Connect cross-installation linking based on the Canvas instance ID may fail because incomplete Canvas Connect course links are considered for matching. ## External Logins ### New Features - When merging user accounts, login emails from accounts that are about to be merged into the primary account are added as secondary login emails. ### Updates & Improvements - When adding new users, more than one external login identifiers can be specified at the user level. Providers can be different per user, including users with no external login identifiers to be added. Previously, only a single identifier was allowed per user from a single provider for all users. ## UI ### New Features - Use the new Canvas Connect sync roster button to sync all linked Canvas Connect courses at once. Similar to Canvas Sync, rate limits apply. - Use the new quick Sync roster button to trigger a sync with Canvas Connect or Canvas Sync without opening the detail view in Manage Group page. ## Internal ### Updates & Improvements - Centralized shared constants for LTI launch and Canvas Connect NRPS sync and shared LTI role helpers.
1 parent 098f110 commit dc6e8ab

20 files changed

Lines changed: 4152 additions & 176 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Add resource_link_id in LTIClass
2+
3+
Revision ID: 3cc56efe20a8
4+
Revises: a384f2bfddf4
5+
Create Date: 2026-02-10 18:00:43.449645
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "3cc56efe20a8"
17+
down_revision: Union[str, None] = "a384f2bfddf4"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
op.add_column(
25+
"lti_classes", sa.Column("resource_link_id", sa.String(), nullable=True)
26+
)
27+
# ### end Alembic commands ###
28+
29+
30+
def downgrade() -> None:
31+
# ### commands auto generated by Alembic - please adjust! ###
32+
op.drop_column("lti_classes", "resource_link_id")
33+
# ### end Alembic commands ###

pingpong/__main__.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
)
5757
from pingpong.now import _get_next_run_time, croner, utcnow
5858
from pingpong.schemas import LMSType, RunStatus
59+
from pingpong.lti.canvas_connect import canvas_connect_sync_all
5960
from pingpong.summary import send_class_summary_for_class
6061

6162
from .auth import encode_auth_token
@@ -97,6 +98,12 @@ def lms() -> None:
9798
pass
9899

99100

101+
@cli.group("lti")
102+
def lti() -> None:
103+
"""LTI Advantage Service commands."""
104+
pass
105+
106+
100107
@cli.group("export")
101108
def export() -> None:
102109
pass
@@ -946,6 +953,23 @@ async def _lms_sync_all(
946953
logger.info("Done!")
947954

948955

956+
async def _lti_sync_all(sync_classes_with_error_status: bool = False) -> None:
957+
lti_settings = config.lti
958+
if lti_settings is None:
959+
logger.error("LTI service is not enabled in configuration")
960+
return
961+
962+
await config.authz.driver.init()
963+
async with config.db.driver.async_session() as session:
964+
async with config.authz.driver.get_client() as c:
965+
await canvas_connect_sync_all(
966+
session=session,
967+
authz_client=c,
968+
sync_classes_with_error_status=sync_classes_with_error_status,
969+
)
970+
logger.info("Done!")
971+
972+
949973
@lms.command("sync-all")
950974
@click.option("--sync-with-error", default=False, is_flag=True)
951975
@click.option("--sync-without-sso", default=False, is_flag=True)
@@ -984,6 +1008,42 @@ async def _sync_pingpong_with_lms():
9841008
asyncio.run(_sync_pingpong_with_lms())
9851009

9861010

1011+
@lti.command("sync-all")
1012+
@click.option("--sync-with-error", default=False, is_flag=True)
1013+
def sync_lti_all(sync_with_error: bool) -> None:
1014+
"""
1015+
Sync all classes linked through Canvas Connect.
1016+
"""
1017+
asyncio.run(
1018+
_lti_sync_all(
1019+
sync_classes_with_error_status=sync_with_error,
1020+
)
1021+
)
1022+
1023+
1024+
@lti.command("sync_pingpong_with_lti")
1025+
@click.option("--crontime", default="0 * * * *")
1026+
@click.option("--host", default="localhost")
1027+
@click.option("--port", default=8001)
1028+
def sync_pingpong_with_lti(crontime: str, host: str, port: int) -> None:
1029+
"""
1030+
Run the LTI sync-all command in a background server.
1031+
"""
1032+
server = get_server(host=host, port=port)
1033+
1034+
async def _sync_pingpong_with_lti():
1035+
async for _ in croner(crontime, logger=logger):
1036+
try:
1037+
await _lti_sync_all()
1038+
logger.info(f"LTI sync completed successfully at {datetime.now()}")
1039+
except Exception as e:
1040+
logger.exception(f"Error during LTI sync: {e}")
1041+
1042+
# Run the Uvicorn server in the background
1043+
with server.run_in_thread():
1044+
asyncio.run(_sync_pingpong_with_lti())
1045+
1046+
9871047
@export.command("export_threads_with_emails")
9881048
@click.argument("class_id", type=int)
9891049
@click.argument("user_email")
@@ -1093,12 +1153,6 @@ async def _send_activity_summaries(
10931153
await session.commit()
10941154

10951155

1096-
@cli.group("lti")
1097-
def lti() -> None:
1098-
"""LTI Advantage Service commands."""
1099-
pass
1100-
1101-
11021156
@lti.command("rotate-keys")
11031157
@click.option("--key-size", default=2048, help="RSA key size in bits")
11041158
@click.option("--retention-count", default=3, help="Number of keys to retain")
@@ -1233,6 +1287,7 @@ async def _test_jwks() -> None:
12331287
FUNCTIONS_MAP: Dict[str, Callable] = {
12341288
"batch_send_activity_summaries": _send_activity_summaries,
12351289
"sync_pingpong_with_lms": lambda _, **kwargs: _lms_sync_all(**kwargs),
1290+
"sync_pingpong_with_lti": lambda _, **kwargs: _lti_sync_all(**kwargs),
12361291
}
12371292

12381293

pingpong/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ class LTISettings(BaseSettings):
328328
"""LTI Advantage Service settings."""
329329

330330
key_store: LTIKeyStoreSettings
331+
sync_wait: int = Field(60 * 10, gt=0) # 10 mins
331332

332333
# Key rotation settings
333334
rotation_schedule: str = Field("0 0 1 * *") # First day of every month at midnight

0 commit comments

Comments
 (0)