Skip to content

Commit

Permalink
Expose annotation stats for a course's assignments
Browse files Browse the repository at this point in the history
  • Loading branch information
marcospri committed May 16, 2024
1 parent 392b5f8 commit 063a9b9
Show file tree
Hide file tree
Showing 10 changed files with 173 additions and 22 deletions.
22 changes: 16 additions & 6 deletions lms/js_config_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ class APICallInfo(TypedDict):
authUrl: NotRequired[str]


class APIAssignment(TypedDict):
id: int
title: str


class APICourse(TypedDict):
id: int
title: str
Expand All @@ -26,13 +21,28 @@ class APIStudentStats(TypedDict):
display_name: str
annotations: int
replies: int
last_activity: str
last_activity: str | None


class AssignmentStats(TypedDict):
annotations: int
replies: int
last_activity: str | None


class APIAssignment(TypedDict):
id: int
title: str
stats: NotRequired[AssignmentStats]


class DashboardRoutes(TypedDict):
assignment: str
assignment_stats: str

course: str
course_assigment_stats: str


class DashboardConfig(TypedDict):
routes: DashboardRoutes
7 changes: 5 additions & 2 deletions lms/models/grouping.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from enum import Enum
from typing import TypedDict
from typing import TYPE_CHECKING, TypedDict

import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
Expand All @@ -10,6 +10,9 @@
from lms.models._mixins import CreatedUpdatedMixin
from lms.models.json_settings import JSONSettings

if TYPE_CHECKING:
from lms.models import Assignment

MAX_GROUP_NAME_LENGTH = 25


Expand Down Expand Up @@ -184,7 +187,7 @@ class GroupSet(TypedDict):
class Course(Grouping):
__mapper_args__ = {"polymorphic_identity": Grouping.Type.COURSE}

assignments = sa.orm.relationship(
assignments: Mapped[list["Assignment"]] = sa.orm.relationship(
"Assignment", secondary="assignment_grouping", viewonly=True
)
"""Assignments that belong to this course."""
Expand Down
3 changes: 3 additions & 0 deletions lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ def enable_dashboard_mode(self):
"dashboard.api.assignment.stats"
),
course=self._to_frontend_template("dashboard.api.course"),
course_assigment_stats=self._to_frontend_template(
"dashboard.api.course.assignments.stats"
),
),
),
}
Expand Down
4 changes: 4 additions & 0 deletions lms/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,7 @@ def includeme(config): # pylint:disable=too-many-statements
"/dashboard/api/assignment/{assignment_id}/stats",
)
config.add_route("dashboard.api.course", "/dashboard/api/course/{course_id}")
config.add_route(
"dashboard.api.course.assignments",
"/dashboard/api/course/{course_id}/assignments/stats",
)
12 changes: 12 additions & 0 deletions lms/services/h_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,18 @@ def get_assignment_stats(
)
return response.json()

def get_course_stats(self, group_authority_ids: list[str]):
response = self._api_request(
"POST",
path="bulk/stats/course",
body=json.dumps({"filter": {"groups": group_authority_ids}}),
headers={
"Content-Type": "application/vnd.hypothesis.v1+json",
},
stream=False,
)
return response.json()

# pylint: disable=too-many-arguments
def _api_request(self, method, path, body=None, headers=None, stream=False):
"""
Expand Down
2 changes: 1 addition & 1 deletion lms/views/dashboard/api/assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def assignment_stats(self) -> list[APIStudentStats]:

# Organize the H stats by userid for quick access
stats_by_user = {s["userid"]: s for s in stats}
student_stats = []
student_stats: list[APIStudentStats] = []

# Iterate over all the students we have in the DB
for user in self.assignment_service.get_members(
Expand Down
48 changes: 47 additions & 1 deletion lms/views/dashboard/api/course.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from pyramid.view import view_config

from lms.js_config_types import APICourse
from lms.js_config_types import (
APIAssignment,
APICourse,
AssignmentStats,
)
from lms.security import Permissions
from lms.services.h_api import HAPI
from lms.views.dashboard.base import get_request_course


class CourseViews:
def __init__(self, request) -> None:
self.request = request
self.course_service = request.find_service(name="course")
self.h_api = request.find_service(HAPI)

@view_config(
route_name="dashboard.api.course",
Expand All @@ -22,3 +28,43 @@ def course(self) -> APICourse:
"id": course.id,
"title": course.lms_name,
}

@view_config(
route_name="dashboard.api.course.assignments",
request_method="GET",
renderer="json",
permission=Permissions.DASHBOARD_VIEW,
)
def course_stats(self) -> list[APIAssignment]:
course = get_request_course(self.request, self.course_service)

stats = self.h_api.get_course_stats(
# Annotations in the course group an any children
[course.authority_provided_id]
+ [child.authority_provided_id for child in course.children]
)
print(stats)
# Organize the H stats by assignment ID for quick access
stats_by_assignment = {s["assignment_id"]: s for s in stats}
assignment_stats: list[APIAssignment] = []

for assignment in course.assignments:
if h_stats := stats_by_assignment.get(assignment.resource_link_id):
stats = AssignmentStats(
annotations=h_stats["annotations"],
replies=h_stats["replies"],
last_activity=h_stats["last_activity"],
)
else:
# Assignment with no annos, zeroing the stats
stats = AssignmentStats(annotations=0, replies=0, last_activity=None)

assignment_stats.append(
APIAssignment(
id=assignment.id,
title=assignment.title,
stats=stats,
)
)

return assignment_stats
1 change: 1 addition & 0 deletions tests/unit/lms/resources/_js_config/__init___test.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ def test_it(self, js_config):
"assignment": "/dashboard/api/assignment/:assignment_id",
"assignment_stats": "/dashboard/api/assignment/:assignment_id/stats",
"course": "/dashboard/api/course/:course_id",
"course_assignment_stats": "/dashboard/api/course/:course_id/assignments/stats",
}
}

Expand Down
39 changes: 28 additions & 11 deletions tests/unit/lms/services/h_api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,27 +132,44 @@ def test_get_annotations(

assert result == expected_result

def test_get_assignment_stats(self, h_api, http_service):
@pytest.mark.parametrize(
"method,url,args,payload",
[
(
"get_assignment_stats",
"https://h.example.com/private/api/bulk/stats/assignment",
(["group_1", "group_2"], "assignment_id"),
{
"filter": {
"groups": ["group_1", "group_2"],
"assignment_id": "assignment_id",
},
},
),
(
"get_course_stats",
"https://h.example.com/private/api/bulk/stats/course",
(["group_1", "group_2"],),
{
"filter": {"groups": ["group_1", "group_2"]},
},
),
],
)
def test_get_stats_endpoints(self, h_api, http_service, method, url, args, payload):
http_service.request.return_value = factories.requests.Response(raw="{}")

h_api.get_assignment_stats(["group_1", "group_2"], "assignment_id")
getattr(h_api, method)(*args)

http_service.request.assert_called_once_with(
method="POST",
url="https://h.example.com/private/api/bulk/stats/assignment",
url=url,
auth=("TEST_CLIENT_ID", "TEST_CLIENT_SECRET"),
headers={
"Content-Type": "application/vnd.hypothesis.v1+json",
"Hypothesis-Application": "lms",
},
data=json.dumps(
{
"filter": {
"groups": ["group_1", "group_2"],
"assignment_id": "assignment_id",
},
}
),
data=json.dumps(payload),
timeout=(60, 60),
stream=False,
)
Expand Down
57 changes: 56 additions & 1 deletion tests/unit/lms/views/dashboard/api/course_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from lms.views.dashboard.api.course import CourseViews
from tests import factories

pytestmark = pytest.mark.usefixtures("course_service")
pytestmark = pytest.mark.usefixtures("course_service", "h_api")


class TestCourseViews:
Expand All @@ -23,6 +23,61 @@ def test_course(self, views, pyramid_request, course_service):
"title": course.lms_name,
}

def test_course_stats(
self, views, pyramid_request, course_service, h_api, db_session
):
pyramid_request.matchdict["course_id"] = sentinel.id
course = factories.Course()
section = factories.CanvasSection(parent=course)
course_service.get_by_id.return_value = course

assignment = factories.Assignment()
assignment_with_no_annos = factories.Assignment()

factories.AssignmentGrouping(assignment=assignment, grouping=course)
factories.AssignmentGrouping(
assignment=assignment_with_no_annos, grouping=course
)
db_session.flush()

stats = [
{
"assignment_id": assignment.resource_link_id,
"annotations": sentinel.annotations,
"replies": sentinel.replies,
"userid": "TEACHER",
"last_activity": sentinel.last_activity,
},
]

h_api.get_course_stats.return_value = stats

response = views.course_stats()

h_api.get_course_stats.assert_called_once_with(
[course.authority_provided_id, section.authority_provided_id]
)
assert response == [
{
"id": assignment.id,
"title": assignment.title,
"stats": {
"annotations": sentinel.annotations,
"replies": sentinel.replies,
"last_activity": sentinel.last_activity,
},
},
{
"id": assignment_with_no_annos.id,
"title": assignment_with_no_annos.title,
"stats": {
"annotations": 0,
"replies": 0,
"last_activity": None,
},
},
]

@pytest.fixture
def views(self, pyramid_request):
return CourseViews(pyramid_request)

0 comments on commit 063a9b9

Please sign in to comment.