Skip to content

Commit 2d579e6

Browse files
bnmnetpclaude
andcommitted
Add LTI 1.1 configuration page to admin server
Ports the legacy web2py LTI config panel (the #SetupLTI div in runestone/views/admin/admin.html) to a real page on the FastAPI admin server, replacing the "coming soon" placeholder in the instructor menu. - New GET /instructor/lti_config page plus create_lti_keys and delete_lti_keys endpoints. - create_lti1p1_config / fetch_lti1p1_config CRUD helpers (delete reuses the existing delete_lti_course). - lti_config.html + lti_config.js mirror the original panel: create / remove 1.1 keys, show/copy secret, and the LTI 1.3 remove-association button and option toggles (1.3 backend unchanged). - Instructor menu links directly to the new page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSYdBnh54WSQhG6Y4MAQ1x
1 parent 86bbe9b commit 2d579e6

8 files changed

Lines changed: 443 additions & 2 deletions

File tree

bases/rsptx/admin_server_api/routers/instructor.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,15 @@
2828
create_course_attribute,
2929
create_instructor_course_entry,
3030
create_invoice_request,
31+
create_lti1p1_config,
3132
create_membership,
3233
create_user_course_entry,
3334
delete_course_completely,
3435
delete_course_instructor,
36+
delete_lti_course,
3537
delete_user_course_entry,
3638
fetch_all_course_attributes,
39+
fetch_lti1p1_config,
3740
fetch_assignment_questions,
3841
fetch_assignments,
3942
fetch_available_students_for_instructor_add,
@@ -431,6 +434,83 @@ async def get_course_settings(
431434
return templates.TemplateResponse("admin/instructor/course_settings.html", context)
432435

433436

437+
@router.get("/lti_config")
438+
@instructor_role_required()
439+
@with_course()
440+
async def get_lti_config(
441+
request: Request,
442+
user=Depends(auth_manager),
443+
response_class=HTMLResponse,
444+
course=None,
445+
):
446+
"""
447+
Display the LTI integration configuration page. Handles LTI 1.1 key/secret
448+
management (the LTI 1.3 pieces are configured elsewhere and are only surfaced
449+
here for informational purposes and to allow removing an association).
450+
"""
451+
templates = Jinja2Templates(directory=template_folder)
452+
453+
lti_key = await fetch_lti1p1_config(course.id)
454+
course_attrs = await fetch_all_course_attributes(course.id)
455+
456+
context = {
457+
"course": course,
458+
"user": user,
459+
"request": request,
460+
"is_instructor": True,
461+
"student_page": False,
462+
"settings": settings,
463+
"consumer": lti_key.consumer if lti_key else "",
464+
"secret": lti_key.secret if lti_key else "",
465+
"ignore_lti_dates": course_attrs.get("ignore_lti_dates") == "true",
466+
"no_lti_auto_grade_update": course_attrs.get("no_lti_auto_grade_update")
467+
== "true",
468+
}
469+
470+
return templates.TemplateResponse("admin/instructor/lti_config.html", context)
471+
472+
473+
@router.post("/create_lti_keys")
474+
@instructor_role_required()
475+
@with_course()
476+
async def post_create_lti_keys(
477+
request: Request,
478+
user=Depends(auth_manager),
479+
course=None,
480+
):
481+
"""
482+
Generate an LTI 1.1 consumer key and secret for this course and store them.
483+
Triggered from the LTI configuration page.
484+
"""
485+
existing = await fetch_lti1p1_config(course.id)
486+
if existing:
487+
return JSONResponse(
488+
content={"consumer": existing.consumer, "secret": existing.secret}
489+
)
490+
491+
lti_key = await create_lti1p1_config(course.course_name, course.id)
492+
return JSONResponse(
493+
content={"consumer": lti_key.consumer, "secret": lti_key.secret}
494+
)
495+
496+
497+
@router.post("/delete_lti_keys")
498+
@instructor_role_required()
499+
@with_course()
500+
async def post_delete_lti_keys(
501+
request: Request,
502+
user=Depends(auth_manager),
503+
course=None,
504+
):
505+
"""
506+
Remove the LTI 1.1 consumer key and secret for this course.
507+
Triggered from the LTI configuration page.
508+
"""
509+
if await fetch_lti1p1_config(course.id):
510+
await delete_lti_course(course.id)
511+
return make_json_response(status=status.HTTP_200_OK, detail={"status": "success"})
512+
513+
434514
# Assessment Reset Model
435515
class AssessmentResetRequest(BaseModel):
436516
student_username: str

components/rsptx/db/crud/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@
139139
# import all functions from .lti by name
140140
from .lti import (
141141
create_lti_course,
142+
create_lti1p1_config,
142143
delete_lti_course,
143144
delete_lti1p3_course,
144145
fetch_lti_version,
146+
fetch_lti1p1_config,
145147
fetch_lti1p3_assignments_by_rs_assignment_id,
146148
fetch_lti1p3_assignments_by_rs_course_id,
147149
fetch_lti1p3_config_by_lti_data,
@@ -339,9 +341,11 @@
339341

340342
__all__ += [
341343
"create_lti_course",
344+
"create_lti1p1_config",
342345
"delete_lti_course",
343346
"delete_lti1p3_course",
344347
"fetch_lti_version",
348+
"fetch_lti1p1_config",
345349
"fetch_lti1p3_assignments_by_rs_assignment_id",
346350
"fetch_lti1p3_assignments_by_rs_course_id",
347351
"fetch_lti1p3_config_by_lti_data",

components/rsptx/db/crud/lti.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from typing import List, Optional
23
from rsptx.configuration import settings
34
from pydal.validators import CRYPT
@@ -413,6 +414,43 @@ async def create_lti_course(course_id: int, lti_id: str) -> CourseLtiMap:
413414
return new_entry
414415

415416

417+
async def fetch_lti1p1_config(course_id: int) -> Optional[LtiKey]:
418+
"""
419+
Fetch the LTI 1.1 key/secret associated with a course, if any.
420+
421+
:param course_id: int, the id of the course
422+
:return: Optional[LtiKey], the LtiKey record for the course or None
423+
"""
424+
query = (
425+
select(LtiKey)
426+
.join(CourseLtiMap, CourseLtiMap.lti_id == LtiKey.id)
427+
.where(CourseLtiMap.course_id == course_id)
428+
)
429+
async with async_session() as session:
430+
res = await session.execute(query)
431+
return res.scalars().first()
432+
433+
434+
async def create_lti1p1_config(course_name: str, course_id: int) -> LtiKey:
435+
"""
436+
Generate an LTI 1.1 consumer key and secret, store them, and associate them
437+
with the given course. There is no real magic about the keys so a UUID is
438+
just as good a solution as anything.
439+
440+
:param course_name: str, the name of the course (used to build the consumer key)
441+
:param course_id: int, the id of the course
442+
:return: LtiKey, the newly created LtiKey record
443+
"""
444+
consumer = f"{course_name}-{uuid.uuid1()}"
445+
secret = str(uuid.uuid4())
446+
new_key = LtiKey(consumer=consumer, secret=secret, application="runestone")
447+
async with async_session.begin() as session:
448+
session.add(new_key)
449+
await session.flush()
450+
session.add(CourseLtiMap(course_id=course_id, lti_id=new_key.id))
451+
return new_key
452+
453+
416454
async def delete_lti_course(course_id: int) -> bool:
417455
"""
418456
Delete a course from the LTI map.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
{% extends "admin/_admin_base.html" %}
2+
3+
{% block title %}
4+
LTI Configuration
5+
{% endblock %}
6+
7+
{% block content %}
8+
<div class="admin-page admin-page--narrow lti-config">
9+
<a href="/admin/instructor/menu" class="back-link">Back to Instructor Dashboard</a>
10+
11+
<div class="page-header">
12+
<h1>LTI Configuration</h1>
13+
</div>
14+
15+
<div class="course-info">
16+
<h3>{{ course.course_name }}</h3>
17+
<p>Base Course: {{ course.base_course }}</p>
18+
</div>
19+
20+
<div id="alert-container"></div>
21+
22+
<div class="panel">
23+
<p>LTI is <strong>Community Supported!</strong> It works with various LMS platforms (Canvas, Sakai,
24+
Moodle, and Desire2Learn) but you need to know how to configure things on your own LMS. There are
25+
two different LTI configurations available: LTI 1.1 (traditional) and LTI 1.3. LTI 1.1 can be
26+
configured by the instructor. LTI 1.3 requires the LMS admin to configure the tool, but is more
27+
secure and has more features.</p>
28+
29+
<p>Please <a href="https://guide.runestone.academy/managing-your-course.html#lti_integration"
30+
rel="noopener noreferrer" target="_blank">read the docs</a> first, then feel free to ask for
31+
help on the <code>#lti_community_support</code> channel on Discord. If you have it working on
32+
another LMS please help us out and contribute to the docs so that others can benefit.</p>
33+
</div>
34+
35+
<div class="panel">
36+
<h2>LTI 1.1 Integration</h2>
37+
<p>To launch LTI on Runestone use <code>https://runestone.academy/runestone/lti</code></p>
38+
<p>You will need to generate a key and secret that will be used to communicate with the LMS. (See
39+
<a href="https://guide.runestone.academy/managing-your-course.html#lti_integration"
40+
rel="noopener noreferrer" target="_blank">the docs</a>.)</p>
41+
42+
<div class="lti-actions">
43+
<button id="create_lti" class="btn btn-primary" onclick="generateLTIKeys()"
44+
{% if consumer %}disabled{% endif %}>Create LTI Key and Secret</button>
45+
<button id="delete_lti" class="btn btn-danger" onclick="deleteLTIKeys()"
46+
{% if not consumer %}disabled{% endif %}>Remove LTI Key and Secret</button>
47+
</div>
48+
49+
<table class="lti-keys-table">
50+
<tr>
51+
<th>Consumer Key:</th>
52+
<td>
53+
<span id="ckey_value">{{ consumer }}</span>
54+
<a class="copy-link" title="Copy" onclick="copyElementToClipboard('ckey_value')">&#128203;</a>
55+
</td>
56+
</tr>
57+
<tr>
58+
<th>Secret:</th>
59+
<td>
60+
<span id="secret_value" data-secret="{{ secret }}">{% if secret %}&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;{% endif %}</span>
61+
<button type="button" class="btn btn-sm" id="show_secret" onclick="toggleSecret()"
62+
{% if not secret %}disabled{% endif %}>Show Secret</button>
63+
<a class="copy-link" title="Copy" onclick="copySecretToClipboard()">&#128203;</a>
64+
</td>
65+
</tr>
66+
</table>
67+
</div>
68+
69+
<div class="panel">
70+
<h2>LTI 1.3 Integration</h2>
71+
<p>Your LMS administrator will need to configure Runestone as a tool available on your LMS. This will
72+
only need to happen one time for your LMS. Once it is configured you can use the tool in any course
73+
you have on Runestone. Point them to
74+
<a href="https://guide.runestone.academy/managing-your-course.html#lti1p3_integration"
75+
rel="noopener noreferrer" target="_blank">the Runestone LTI 1.3 documentation</a>. (This means
76+
this integration is NOT available if you are using the free Canvas for Teachers or some other hosted
77+
LMS that does not have a system administrator you can contact.)</p>
78+
79+
<p>Each Runestone course can only be linked to ONE LMS course via LTI 1.3. You can use multiple different
80+
Runestone courses (to make use of multiple books) in one LMS course. If you need to disassociate this
81+
Runestone course from any LTI links, you can do so by clicking the button below. Doing so will NOT
82+
delete any assignments or grades in the LMS, but will remove the link between the two systems. You
83+
can even relink the course to the same LMS course later if you need to.</p>
84+
85+
<div class="lti-actions">
86+
<button id="delete_lti1p3" class="btn btn-danger" onclick="deleteLTI1p3()">Remove LTI 1.3
87+
Association</button>
88+
</div>
89+
90+
<h3>LTI 1.3 Options</h3>
91+
92+
<div class="settingsbox">
93+
<div class="checkbox-container">
94+
<input type="checkbox" id="ignore_lti_dates" {% if ignore_lti_dates %}checked{% endif %}
95+
onchange="updateCourse(this,'ignore_lti_dates')">
96+
<label for="ignore_lti_dates">Ignore LTI 1.3 Date Changes</label>
97+
</div>
98+
<div class="setting-description">Runestone will try to update assignment due dates. Check to disable
99+
automatic updates of assignment due dates from LMS assignment settings.</div>
100+
</div>
101+
102+
<div class="settingsbox">
103+
<div class="checkbox-container">
104+
<input type="checkbox" id="no_lti_auto_grade_update"
105+
{% if no_lti_auto_grade_update %}checked{% endif %}
106+
onchange="updateCourse(this,'no_lti_auto_grade_update')">
107+
<label for="no_lti_auto_grade_update">Do not auto update LTI grades</label>
108+
</div>
109+
<div class="setting-description">Runestone will try to update assignment scores in your LMS any time
110+
a student completes a problem if that assignment's grades are set to released. Check this to
111+
disable this behavior and only sync grades when you trigger it from the grading page.</div>
112+
</div>
113+
</div>
114+
</div>
115+
{% endblock %}
116+
117+
{% block page_js %}
118+
<script src="/staticAssets/js/admin/lti_config.js"></script>
119+
{% endblock %}

components/rsptx/templates/admin/instructor/menu.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ <h4>⚙️ Create a New Course</h4>
160160
<h4>Manage API Tokens</h4>
161161
<p>Add and manage API tokens for external services</p>
162162
</a>
163-
<a href="#" class="menu-item" onclick="showLTISetup()">
163+
<a href="/admin/instructor/lti_config" class="menu-item">
164164
<h4>Connect my Learning Management System</h4>
165165
<p>Configure Learning Tools Interoperability settings</p>
166166
</a>

components/rsptx/templates/staticAssets/css/admin.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,3 +771,60 @@ tr.duplicate {
771771
max-width: 300px;
772772
}
773773
}
774+
775+
/* LTI configuration page (admin/instructor/lti_config.html) */
776+
.lti-config .panel {
777+
margin-bottom: 20px;
778+
}
779+
780+
.lti-config .panel h2 {
781+
margin: 0 0 15px 0;
782+
color: #333;
783+
font-size: 1.4em;
784+
padding-bottom: 10px;
785+
border-bottom: 2px solid #4a90e2;
786+
}
787+
788+
.lti-config .panel h3 {
789+
text-align: left;
790+
font-size: 1.15em;
791+
border-bottom: none;
792+
margin-top: 20px;
793+
}
794+
795+
.lti-config .lti-actions {
796+
display: flex;
797+
flex-wrap: wrap;
798+
gap: 10px;
799+
margin: 15px 0;
800+
}
801+
802+
.lti-config .btn-sm {
803+
padding: 4px 10px;
804+
font-size: 0.85em;
805+
}
806+
807+
.lti-config .lti-keys-table {
808+
width: 100%;
809+
border-collapse: collapse;
810+
margin-top: 10px;
811+
}
812+
813+
.lti-config .lti-keys-table th {
814+
text-align: left;
815+
white-space: nowrap;
816+
padding: 8px 12px 8px 0;
817+
vertical-align: top;
818+
width: 130px;
819+
}
820+
821+
.lti-config .lti-keys-table td {
822+
padding: 8px 0;
823+
word-break: break-all;
824+
}
825+
826+
.lti-config .copy-link {
827+
cursor: pointer;
828+
margin-left: 6px;
829+
text-decoration: none;
830+
}

0 commit comments

Comments
 (0)