diff --git a/src/accounts/serializers.py b/src/accounts/serializers.py index e47987b..5fc3873 100644 --- a/src/accounts/serializers.py +++ b/src/accounts/serializers.py @@ -1,12 +1,13 @@ +from django.contrib.auth import authenticate from django.contrib.auth.models import User -from accounts.models import MyUser from django.contrib.auth.password_validation import validate_password from rest_framework import serializers from rest_framework.validators import UniqueValidator + +import accounts.error_messages as ERROR_MESSAGES +from accounts.models import MyUser from app.models.course_member import CourseMember, CourseMemberTokenError from app.serializers.course_member import CourseMemberListSerializer -import accounts.error_messages as ERROR_MESSAGES -from django.contrib.auth import authenticate class StudentMemberSerializer(serializers.ModelSerializer): diff --git a/src/app/canvas/import_students.py b/src/app/canvas/import_students.py index 87908aa..3a167a7 100644 --- a/src/app/canvas/import_students.py +++ b/src/app/canvas/import_students.py @@ -1,20 +1,20 @@ -from typing import List +from typing import List, Tuple -from canvasapi.quiz import QuizQuestion -from app.models.course import Course from canvasapi import Canvas from canvasapi.enrollment import Enrollment +from canvasapi.quiz import QuizQuestion +from app.models.course import Course from app.models.course_member import CourseMember, UserRole from app.models.organization import LMSTypeOptions -def import_students_from_canvas(course: Course): +def import_students_from_canvas(course: Course) -> Tuple[int, int]: if ( course.organization is None or course.organization.lms_type != LMSTypeOptions.CANVAS ): - return + return 0, 0 canvas = Canvas(course.organization.lms_api_url, course.lms_access_token) canvas_course = canvas.get_course(course.lms_course_id) @@ -22,6 +22,7 @@ def import_students_from_canvas(course: Course): students: List[Enrollment] = list( canvas_course.get_enrollments(type=["StudentEnrollment"]) ) + total_students = len(students) if course.lms_opt_in_quiz_id is not None: opted_in_ids = set() @@ -65,3 +66,5 @@ def import_students_from_canvas(course: Course): course_id=course.pk, role=UserRole.STUDENT, ) + + return total_students, len(students) diff --git a/src/app/serializers/course_member.py b/src/app/serializers/course_member.py index bd08241..1b9c6c6 100644 --- a/src/app/serializers/course_member.py +++ b/src/app/serializers/course_member.py @@ -41,6 +41,9 @@ class Meta: class CourseMemberListSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="course.id") + name = serializers.CharField(source="course.name") + class Meta: model = CourseMember - fields = ("course",) + fields = ("id", "name") diff --git a/src/app/tests/__init__.py b/src/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/tests/test_views/__init__.py b/src/app/tests/test_views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/tests/test_views/test_attribute.py b/src/app/tests/test_views/test_attribute.py new file mode 100644 index 0000000..4d4372f --- /dev/null +++ b/src/app/tests/test_views/test_attribute.py @@ -0,0 +1,277 @@ +import json +from unittest import mock +from django.forms import ValidationError +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.test import TransactionTestCase +from rest_framework.test import APIRequestFactory +from rest_framework.request import Request +from app.models import Attribute +from app.models.attribute import AttributeOption +from app.models.course import Course +from app.views.attribute import AttributeViewSet +from rest_framework.parsers import JSONParser +from django.core.exceptions import FieldError + +# Create your tests here. + + +class TestAttribute(TransactionTestCase): + reset_sequences = True + + def setUp(self): + self.course = Course.objects.create(name="test") + + def tearDown(self): + Attribute.objects.all().delete() + AttributeOption.objects.all().delete() + Course.objects.all().delete() + + def test_save_attribute_without_options(self): + data = { + "name": "name", + "question": "question", + "value_type": "String", + "max_selections": 1, + "team_set_template": None, + "course": self.course.pk, + } + + with self.assertRaises(FieldError) as context: + AttributeViewSet.save_attribute(AttributeViewSet, get_post_request(data)) + + self.assertTrue("Options field is required" in str(context.exception)) + + def test_save_attribute_with_non_existent_id_raises_404(self): + data = { + "id": 100, + "name": "name", + "question": "question", + "value_type": "String", + "max_selections": 1, + "team_set_template": None, + "course": self.course.pk, + } + + with self.assertRaises(Http404) as context: + AttributeViewSet.save_attribute(AttributeViewSet, get_post_request(data)) + + self.assertTrue( + "No Attribute matches the given query" in str(context.exception) + ) + + def test_save_attribute_without_id_creates_new_attribute(self): + data = { + "name": "name", + "question": "question", + "value_type": "String", + "max_selections": 1, + "team_set_template": None, + "course": self.course.pk, + "options": [], + } + + attribute = AttributeViewSet.save_attribute( + AttributeViewSet, get_post_request(data) + ) + + self.assertEqual(Attribute.objects.count(), 1) + + attribute = get_object_or_404( + Attribute, pk=attribute.data.get("data").get("id") + ) + self.assertEqual(attribute.name, "name") + self.assertEqual(attribute.question, "question") + self.assertEqual(attribute.value_type, "String") + self.assertEqual(attribute.max_selections, 1) + self.assertEqual(attribute.team_set_template, None) + self.assertEqual(attribute.course.pk, self.course.pk) + self.assertEqual(attribute.options.count(), 0) + + def test_save_attribute_with_id_updates_old_attribute(self): + attribute = Attribute.objects.create( + name="name1", + question="question1", + value_type="String", + max_selections=1, + team_set_template=None, + course=self.course, + ) + + data = { + "id": attribute.pk, + "name": "name2", + "question": "question2", + "value_type": "Number", + "max_selections": 2, + "team_set_template": None, + "course": self.course.pk, + "options": [], + } + + updated_attribute = AttributeViewSet.save_attribute( + AttributeViewSet, get_post_request(data) + ) + + self.assertEqual(Attribute.objects.count(), 1) + + updated_attribute = get_object_or_404( + Attribute, pk=updated_attribute.data.get("data").get("id") + ) + self.assertEqual(updated_attribute.name, "name2") + self.assertEqual(updated_attribute.question, "question2") + self.assertEqual(updated_attribute.value_type, "Number") + self.assertEqual(updated_attribute.max_selections, 2) + self.assertEqual(updated_attribute.team_set_template, None) + self.assertEqual(updated_attribute.course.pk, self.course.pk) + self.assertEqual(updated_attribute.options.count(), 0) + + @mock.patch("app.models.attribute.Attribute.delete_student_responses") + def test_save_attribute_with_id_and_different_value_type_clears_attribute_responses( + self, mock_delete_student_responses + ): + attribute = Attribute.objects.create( + name="name1", + question="question1", + value_type="String", + max_selections=1, + team_set_template=None, + course=self.course, + ) + + data = { + "id": attribute.pk, + "name": "name1", + "question": "question1", + "value_type": "Number", + "max_selections": 1, + "team_set_template": None, + "course": self.course.pk, + "options": [], + } + + AttributeViewSet.save_attribute(AttributeViewSet, get_post_request(data)) + + mock_delete_student_responses.assert_called_once() + + def test_save_attribute_options_without_id_creates_new_attribute_option(self): + attribute = Attribute.objects.create( + name="test", + question="test", + value_type="String", + max_selections=1, + team_set_template=None, + course=self.course, + ) + + options = [ + {"value": "value1", "label": "label1"}, + {"value": "value2", "label": "label2"}, + ] + + for option in options: + AttributeViewSet.save_attribute_option( + AttributeViewSet, option, attribute.pk + ) + + self.assertEqual(AttributeOption.objects.count(), 2) + + attribute_options = AttributeOption.objects.all() + self.assertEqual(attribute_options[0].value, "value1") + self.assertEqual(attribute_options[0].label, "label1") + self.assertEqual(attribute_options[0].attribute.pk, attribute.pk) + self.assertEqual(attribute_options[1].value, "value2") + self.assertEqual(attribute_options[1].label, "label2") + self.assertEqual(attribute_options[1].attribute.pk, attribute.pk) + + def test_save_attribute_options_with_id_updates_old_attribute_option(self): + attribute = Attribute.objects.create( + name="test", + question="test", + value_type="String", + max_selections=1, + team_set_template=None, + course=self.course, + ) + attribute_option = AttributeOption.objects.create( + attribute=attribute, label="label1", value="value1" + ) + + option = { + "id": attribute_option.pk, + "value": "value2", + "label": "label2", + } + + AttributeViewSet.save_attribute_option(AttributeViewSet, option, attribute.pk) + + self.assertEqual(AttributeOption.objects.count(), 1) + + updated_attribute_option = get_object_or_404( + AttributeOption, pk=attribute_option.pk + ) + self.assertEqual(updated_attribute_option.value, "value2") + self.assertEqual(updated_attribute_option.label, "label2") + self.assertEqual(updated_attribute_option.attribute.pk, attribute.pk) + + def test_save_attribute_options_with_non_existent_id_raises_404(self): + attribute = Attribute.objects.create( + name="test", + question="test", + value_type="String", + max_selections=1, + team_set_template=None, + course=self.course, + ) + + option = { + "id": 100, + "value": "value2", + "label": "label2", + } + + with self.assertRaises(Http404) as context: + AttributeViewSet.save_attribute_option( + AttributeViewSet, option, attribute.pk + ) + + self.assertTrue( + "No AttributeOption matches the given query" in str(context.exception) + ) + + def test_save_attribute_with_null_max_selection_throws_validation_error(self): + attribute = Attribute.objects.create( + name="name1", + question="question1", + value_type="String", + max_selections=1, + team_set_template=None, + course=self.course, + ) + + data = { + "id": attribute.pk, + "name": "name2", + "question": "question2", + "value_type": "Number", + "max_selections": None, + "team_set_template": None, + "course": self.course.pk, + "options": [], + } + + with self.assertRaises(ValidationError) as context: + AttributeViewSet.save_attribute(AttributeViewSet, get_post_request(data)) + + self.assertIsNotNone(context.exception.error_dict.get("max_selections")) + + +def get_post_request(data): + factory = APIRequestFactory() + factory_request = factory.post( + "/api/v1/attributes/save_attribute/", + content_type="application/json", + data=json.dumps(data), + ) + request = Request(factory_request, parsers=[JSONParser()]) + return request diff --git a/src/app/views/course.py b/src/app/views/course.py index d8f830d..1d98018 100644 --- a/src/app/views/course.py +++ b/src/app/views/course.py @@ -1,8 +1,10 @@ from typing import Type -from rest_framework import serializers, viewsets, status, mixins + +from rest_framework import mixins, serializers, status, viewsets +from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from rest_framework.decorators import action + from app.canvas.export_team import export_team_to_canvas from app.canvas.import_attribute import import_gradebook_attribute_from_canvas from app.canvas.import_students import import_students_from_canvas @@ -241,3 +243,53 @@ def get_grade_attributes(self, request, pk=None): AttributeSerializer(grade_attributes, many=True).data, status=status.HTTP_200_OK, ) + + @action( + detail=True, + methods=["get"], + serializer_class=CourseMemberSerializer, + pagination_class=ExamplePagination, + permission_classes=[IsCourseInstructor], + url_path="previous-attributes", + ) + def previous_team_attributes(self, request, pk=None): + course = self.get_object() + + latest_team_set = ( + TeamSet.objects.filter(course=course).order_by("-updated_at").first() + ) + if not latest_team_set: + return Response({"error": "No team sets found for this course"}, status=404) + + attributes_used = Attribute.objects.filter( + teamrequirement__team__in=latest_team_set.teams.all() + ).distinct() + + serialized_attributes = AttributeSerializer(attributes_used, many=True).data + + response_data = { + "team_set_name": latest_team_set.name, + "formation_date": latest_team_set.updated_at, + "total_attributes_used": attributes_used.count(), + "attributes": serialized_attributes, + } + + return Response(response_data) + + @action( + detail=True, + methods=["get"], + permission_classes=[IsCourseInstructor], + url_path="student-counts", + ) + def get_student_counts(self, request, pk=None): + course = self.get_object() + total_students, opted_in_students = import_students_from_canvas(course) + + return Response( + { + "total_students": total_students, + "opted_in_students": opted_in_students, + }, + status=200, + )