diff --git a/lms/js_config_types.py b/lms/js_config_types.py index 1e312b9120..ff04273055 100644 --- a/lms/js_config_types.py +++ b/lms/js_config_types.py @@ -12,11 +12,6 @@ class APICallInfo(TypedDict): authUrl: NotRequired[str] -class APIAssignment(TypedDict): - id: int - title: str - - class APICourse(TypedDict): id: int title: str @@ -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 diff --git a/lms/models/grouping.py b/lms/models/grouping.py index d091edb6a3..7d09bb51dd 100644 --- a/lms/models/grouping.py +++ b/lms/models/grouping.py @@ -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 @@ -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 @@ -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.""" diff --git a/lms/resources/_js_config/__init__.py b/lms/resources/_js_config/__init__.py index 66200a167a..7f745d828c 100644 --- a/lms/resources/_js_config/__init__.py +++ b/lms/resources/_js_config/__init__.py @@ -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" + ), ), ), } diff --git a/lms/routes.py b/lms/routes.py index bcc12edcaa..78e16b8d48 100644 --- a/lms/routes.py +++ b/lms/routes.py @@ -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", + ) diff --git a/lms/services/h_api.py b/lms/services/h_api.py index 2d3cf06dc8..3c9e8a1ac6 100644 --- a/lms/services/h_api.py +++ b/lms/services/h_api.py @@ -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): """ diff --git a/lms/views/dashboard/api/assignment.py b/lms/views/dashboard/api/assignment.py index a985aa486a..200ac06625 100644 --- a/lms/views/dashboard/api/assignment.py +++ b/lms/views/dashboard/api/assignment.py @@ -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( diff --git a/lms/views/dashboard/api/course.py b/lms/views/dashboard/api/course.py index bbb26dc632..dcf9c587e2 100644 --- a/lms/views/dashboard/api/course.py +++ b/lms/views/dashboard/api/course.py @@ -1,7 +1,12 @@ 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 @@ -9,6 +14,7 @@ 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", @@ -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 diff --git a/tests/unit/lms/resources/_js_config/__init___test.py b/tests/unit/lms/resources/_js_config/__init___test.py index 29119e46ba..6d8c5f072a 100644 --- a/tests/unit/lms/resources/_js_config/__init___test.py +++ b/tests/unit/lms/resources/_js_config/__init___test.py @@ -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", } } diff --git a/tests/unit/lms/services/h_api_test.py b/tests/unit/lms/services/h_api_test.py index fdc7ea8d08..23c17f5df8 100644 --- a/tests/unit/lms/services/h_api_test.py +++ b/tests/unit/lms/services/h_api_test.py @@ -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, ) diff --git a/tests/unit/lms/views/dashboard/api/course_test.py b/tests/unit/lms/views/dashboard/api/course_test.py index ccbb141df6..3680e33773 100644 --- a/tests/unit/lms/views/dashboard/api/course_test.py +++ b/tests/unit/lms/views/dashboard/api/course_test.py @@ -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: @@ -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)