Skip to content

Commit c20cfe1

Browse files
dashboard updates (#267)
* added modal for options * added modal for individual student * added help walkthrough * updated google assignments to generic endpoint, started schoology assignment integration, and assignment dashboard piece * updated expanded student info and added document to student cards * brought llm feedback dashboard up to speed with other one * fixed auth headers for lti calls * fixed roster check for appropriate schoology keyword * added text in title option to document source aio * cleaned up schoology items * made it so when assignments are not available, we don't show them * fixed dag delay * fixed commented out code * pr feedback
1 parent 13cb495 commit c20cfe1

20 files changed

Lines changed: 2038 additions & 728 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.0+2026.02.17T15.09.06.345Z.5f75e49b.berickson.20260217.updated.dead.links
1+
0.1.0+2026.02.20T19.02.56.547Z.85e344b1.berickson.20260210.dashboard.updates

learning_observer/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.0+2026.02.17T15.09.06.345Z.5f75e49b.berickson.20260217.updated.dead.links
1+
0.1.0+2026.02.20T19.02.56.547Z.85e344b1.berickson.20260210.dashboard.updates

learning_observer/learning_observer/integrations/google.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,16 @@ def clean_assignment_docs(google_json):
231231
Retrieve set of documents per student associated with an assignment
232232
'''
233233
student_submissions = google_json.get('studentSubmissions', [])
234+
cleaned_submissions = []
234235
for student_json in student_submissions:
235236
google_id = student_json[constants.USER_ID]
236237
local_id = learning_observer.auth.google_id_to_user_id(google_id)
237-
student_json[constants.USER_ID] = local_id
238238
docs = [d['driveFile'] for d in learning_observer.util.get_nested_dict_value(student_json, 'assignmentSubmission.attachments', []) if 'driveFile' in d]
239-
student_json['documents'] = docs
240-
# TODO we should probably remove some of the keys provided
241-
return student_submissions
239+
cleaned_submissions.append({
240+
constants.USER_ID: local_id,
241+
'documents': docs
242+
})
243+
return cleaned_submissions
242244

243245

244246
if __name__ == '__main__':

learning_observer/learning_observer/integrations/schoology.py

Lines changed: 78 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,49 @@
1+
# learning_observer/integrations/schoology.py
12
import learning_observer.constants as constants
2-
import learning_observer.kvs
33
import learning_observer.settings as settings
44

55
from . import util
66

77
API = 'schoology'
88

9+
# All endpoints use LTI service URLs, not direct Schoology REST API.
10+
# These are accessed using the LTI access token negotiated at launch.
11+
#
12+
# LTI AGS spec: https://www.imsglobal.org/spec/lti-ags/v2p0
13+
# LTI NRPS spec: https://www.imsglobal.org/spec/lti-nrps/v2p0
14+
LTI_SERVICE_BASE = 'https://lti-service.svc.schoology.com/lti-service/tool/{clientId}'
15+
916
ENDPOINTS = list(map(lambda x: util.Endpoint(**x, api_name=API), [
10-
{'name': 'course_list', 'remote_url': 'https://lti-service.svc.schoology.com/lti-service/tool/{clientId}/services/names-roles/v2p0/membership/{courseId}', 'headers': {'Accept': 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'}},
11-
{'name': 'course_roster', 'remote_url': 'https://lti-service.svc.schoology.com/lti-service/tool/{clientId}/services/names-roles/v2p0/membership/{courseId}', 'headers': {'Accept': 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'}},
12-
{'name': 'course_assignments', 'remote_url': 'https://api.schoology.com/v1/sections/{courseId}/assignments', 'headers': {'Accept': 'application/vnd.ims.lis.v2.lineitemcontainer+json'}},
17+
{
18+
'name': 'course_list',
19+
'remote_url': f'{LTI_SERVICE_BASE}/services/names-roles/v2p0/membership/{{courseId}}',
20+
'headers': {'Accept': 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'} # required Schoology header for LTI membership requests
21+
},
22+
{
23+
'name': 'course_roster',
24+
'remote_url': f'{LTI_SERVICE_BASE}/services/names-roles/v2p0/membership/{{courseId}}',
25+
'headers': {'Accept': 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json'} # required Schoology header for LTI membership requests
26+
},
27+
{
28+
# AGS line item container — lists all assignments for the course
29+
'name': 'course_assignments',
30+
'remote_url': f'{LTI_SERVICE_BASE}/services/ags/v2p0/lineitems/{{courseId}}',
31+
'headers': {'Accept': 'application/vnd.ims.lis.v2.lineitemcontainer+json'}
32+
},
33+
{
34+
# AGS results for a specific line item — lists per-student results/submissions
35+
# The lineItemId is typically the full line item URL or the trailing segment.
36+
'name': 'assignment_results',
37+
'remote_url': f'{LTI_SERVICE_BASE}/services/ags/v2p0/lineitems/{{courseId}}/{{courseWorkId}}/results',
38+
'headers': {'Accept': 'application/vnd.ims.lis.v2.resultcontainer+json'}
39+
},
1340
]))
1441

1542
register_cleaner = util.make_cleaner_registrar(ENDPOINTS)
1643

1744

1845
def register_endpoints(app):
19-
'''
20-
'''
46+
'''Register Schoology LTI endpoints with the application.'''
2147
if not settings.feature_flag('schoology_routes'):
2248
return
2349

@@ -29,6 +55,10 @@ def register_endpoints(app):
2955
)
3056

3157

58+
# ---------------------------------------------------------------------------
59+
# Course list
60+
# ---------------------------------------------------------------------------
61+
3262
@register_cleaner('course_list', 'courses')
3363
def clean_course_list(schoology_json):
3464
'''
@@ -45,21 +75,27 @@ def clean_course_list(schoology_json):
4575
return [course]
4676

4777

78+
# ---------------------------------------------------------------------------
79+
# Roster
80+
# ---------------------------------------------------------------------------
81+
4882
def _process_schoology_user_for_system(member, google_id):
49-
# Skip if no canvas id
50-
canvas_id = member.get('user_id')
51-
if not canvas_id: return None
83+
'''Convert an LTI NRPS member record into an internal user dict.'''
84+
lti_user_id = member.get('user_id')
85+
if not lti_user_id:
86+
return None
5287

53-
# Skip non students
54-
is_student = 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner' in member.get('roles', [])
88+
is_student = (
89+
'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'
90+
in member.get('roles', [])
91+
)
5592
if not is_student:
5693
return None
5794

58-
# Create user for our system
5995
email = member.get('email')
6096
local_id = google_id
6197
if not local_id:
62-
local_id = f'canvas-{canvas_id}'
98+
local_id = f'schoology-{lti_user_id}'
6399

64100
member[constants.USER_ID] = local_id
65101
user = {
@@ -73,18 +109,16 @@ def _process_schoology_user_for_system(member, google_id):
73109
'photo_url': member.get('picture')
74110
},
75111
constants.USER_ID: local_id,
76-
# TODO is this needed? Other roster functions in LO include it
77-
# 'course_id': course_id
78112
}
79113
return user
80114

81115

82116
@register_cleaner('course_roster', 'roster')
83117
async def clean_course_roster(schoology_json):
84118
'''
85-
Retrieve and clean the roster for a Canvas course, alphabetically sorted
119+
Retrieve and clean the roster for a Schoology course, alphabetically sorted.
86120
87-
Conforms to LTI NRPS v2 response format
121+
Conforms to LTI NRPS v2 response format.
88122
https://www.imsglobal.org/spec/lti-nrps/v2p0
89123
'''
90124
members = schoology_json.get('members', [])
@@ -97,16 +131,37 @@ async def clean_course_roster(schoology_json):
97131
user = _process_schoology_user_for_system(member, google_id)
98132
if user is not None:
99133
users.append(user)
134+
135+
users.sort(
136+
key=lambda user: user.get('profile', {}).get('name', {}).get('family_name', '')
137+
)
100138
return users
101139

102140

103-
@register_cleaner('course_assignments', 'assignments')
141+
# ---------------------------------------------------------------------------
142+
# Assignments (AGS line items)
143+
# ---------------------------------------------------------------------------
144+
@register_cleaner('course_assignments', 'assignments')
104145
def clean_course_assignments(schoology_json):
105146
'''
106-
Clean course line items (assignments) from Schoology
147+
TODO implemement this function
148+
When launching via LTI, Schoology only allows us to see assignments
149+
created by our tool. To see all assignments we require an Oauth workflow.
150+
Clean course line items (assignments) from Schoology via LTI AGS.
151+
'''
152+
raise NotImplemented('Schoology assignments have not yet been implemented.')
153+
107154

108-
Conforms to LTI AGS response format
109-
https://www.imsglobal.org/spec/lti-ags/v2p0
155+
# ---------------------------------------------------------------------------
156+
# Assignment results / assigned docs (AGS results)
157+
# ---------------------------------------------------------------------------
158+
@register_cleaner('assignment_results', 'assigned_docs')
159+
async def clean_assigned_docs(schoology_json):
160+
'''
161+
TODO implemement this function
162+
When launching via LTI, Schoology only allows us to see assignments
163+
created by our tool. To see all assignments we require an Oauth workflow.
164+
Extract per-student Google Doc attachments from LTI AGS results
165+
for a single assignment.
110166
'''
111-
# print('Schoology assignments TODO', schoology_json)
112-
return []
167+
raise NotImplemented('Schoology documents from assignments have not yet been implemented.')

learning_observer/learning_observer/integrations/util.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import aiohttp
2121
import aiohttp.web
22+
import aiohttp_session
2223

2324
import learning_observer.constants as constants
2425
import learning_observer.settings as settings
@@ -99,12 +100,24 @@ async def raw_api_ajax(
99100
request = runtime.get_request()
100101
url = target_url.format(**kwargs)
101102
user = await learning_observer.auth.get_active_user(request)
102-
if constants.AUTH_HEADERS not in request or user is None:
103+
104+
# Auth headers may live on the request (set during the LTI launch
105+
# redirect) OR in the session (persisted for subsequent requests).
106+
# We need to check both sources.
107+
auth_headers = request.get(constants.AUTH_HEADERS)
108+
if auth_headers is None:
109+
session = await aiohttp_session.get_session(request)
110+
auth_headers = session.get(constants.AUTH_HEADERS)
111+
# Populate request so downstream code can find them too
112+
if auth_headers is not None:
113+
request[constants.AUTH_HEADERS] = auth_headers
114+
115+
if auth_headers is None or user is None:
103116
raise aiohttp.web.HTTPUnauthorized(text="Please log in")
104117

105118
if headers is None:
106119
headers = {}
107-
headers.update(request.get(constants.AUTH_HEADERS, {}))
120+
headers.update(auth_headers)
108121

109122
method = method.lower()
110123
cache_available = method == 'get' and cache is not None and cache_key_prefix is not None

learning_observer/learning_observer/rosters.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def init():
360360
# Google, Canvas, and Schoology all use integrations instead of ajax when called
361361
elif roster_source in ["google_api"]:
362362
ajax = google_ajax
363-
elif roster_source in ["canvas_api", 'schoology_api']:
363+
elif roster_source in ["canvas_api", 'schoology']:
364364
pass
365365
elif roster_source in ["all"]:
366366
ajax = all_ajax
@@ -369,7 +369,7 @@ def init():
369369
"Settings file `roster_data` element should have `source` field\n"
370370
"set to either:\n"
371371
" test (retrieve from files courses.json and students.json)\n"
372-
" google_api | canvas_api | schoology_api (retrieve roster data from an api)\n"
372+
" google_api | canvas_api | schoology (retrieve roster data from an api)\n"
373373
" filesystem (retrieve roster data from file system hierarchy\n"
374374
" all (retrieve roster data as all students)"
375375
)
@@ -555,16 +555,55 @@ async def courseroster(request, course_id):
555555
return roster
556556

557557

558+
async def courseassignments(request, course_id):
559+
'''Fetch all the assignments for a given course
560+
'''
561+
assignments = await run_additional_module_func(request, 'assignments', kwargs={'courseId': course_id})
562+
if assignments is not None:
563+
return assignments
564+
return []
565+
566+
567+
async def courseassignment_assigned_docs(request, course_id, assignment_id):
568+
'''
569+
Fetch all assigned docs for a given assignment
570+
'''
571+
assigned_docs = await run_additional_module_func(request, 'assigned_docs', kwargs={'courseId': course_id, 'courseWorkId': assignment_id})
572+
if assigned_docs is not None:
573+
return assigned_docs
574+
return []
575+
576+
558577
async def courselist_api(request):
559578
'''
560579
List all of the courses a teacher manages: Handler
561580
'''
562581
return aiohttp.web.json_response(await courselist(request))
563582

564583

584+
async def course_api(request):
585+
'''
586+
Fetch course information
587+
'''
588+
course_id = request.match_info['course_id']
589+
courses = await courselist(request)
590+
for course in courses:
591+
if course['id'] == course_id:
592+
return aiohttp.web.json_response(course)
593+
return aiohttp.web.json_response({})
594+
595+
565596
async def courseroster_api(request):
566597
'''
567598
List all of the students in a course: Handler
568599
'''
569600
course_id = int(request.match_info['course_id'])
570601
return aiohttp.web.json_response(await courseroster(request, course_id))
602+
603+
604+
async def courseassignments_api(request):
605+
'''
606+
List all of the assignments in a course: Handler
607+
'''
608+
course_id = request.match_info['course_id']
609+
return aiohttp.web.json_response(await courseassignments(request, course_id))

learning_observer/learning_observer/routes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,15 @@ def tracemalloc_handler(request):
7272
aiohttp.web.get(
7373
'/webapi/courselist/',
7474
rosters.courselist_api),
75+
aiohttp.web.get(
76+
'/webapi/course/{course_id}',
77+
rosters.course_api),
7578
aiohttp.web.get(
7679
'/webapi/courseroster/{course_id}',
7780
rosters.courseroster_api),
81+
aiohttp.web.get(
82+
'/webapi/courseassignments/{course_id}',
83+
rosters.courseassignments_api),
7884
])
7985

8086
register_auth_webapp_views(app)

0 commit comments

Comments
 (0)