diff --git a/backend/courses/admin.py b/backend/courses/admin.py index d08ea0682..d058f6142 100644 --- a/backend/courses/admin.py +++ b/backend/courses/admin.py @@ -77,7 +77,7 @@ class CourseAdmin(admin.ModelAdmin): readonly_fields = ("topic", "crosslistings", "course_attributes") exclude = ("attributes",) list_filter = ("semester",) - list_display = ("full_code", "semester", "title") + list_display = ("id", "full_code", "semester", "title") list_select_related = ("department",) diff --git a/backend/degree/admin.py b/backend/degree/admin.py index 56f706d35..35646f73a 100644 --- a/backend/degree/admin.py +++ b/backend/degree/admin.py @@ -4,7 +4,7 @@ from django.urls import reverse from django.utils.html import format_html -from degree.models import Degree, DegreePlan, DoubleCountRestriction, Rule, SatisfactionStatus, Fulfillment, DegreeProfile, CourseTaken +from degree.models import Degree, DegreePlan, DoubleCountRestriction, Rule, SatisfactionStatus, Fulfillment, DegreeProfile, CourseTaken, UserProfile # Register your models here. @@ -54,6 +54,13 @@ def get_urls(self): def degree_editor(self, request): context = dict(self.admin_site.each_context(request)) return TemplateResponse(request, "degree-editor.html", context) + -admin.site.register(DegreeProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ('id', 'user') +admin.site.register(UserProfile, UserProfileAdmin) + +class DegreeProfileAdmin(admin.ModelAdmin): + list_display = ('id', 'user_profile', 'graduation_date') +admin.site.register(DegreeProfile, DegreeProfileAdmin) admin.site.register(CourseTaken) \ No newline at end of file diff --git a/backend/degree/management/commands/fetch_degrees.py b/backend/degree/management/commands/fetch_degrees.py index f5a9d613a..dba4d8eeb 100644 --- a/backend/degree/management/commands/fetch_degrees.py +++ b/backend/degree/management/commands/fetch_degrees.py @@ -6,7 +6,7 @@ from courses.util import get_current_semester from degree.management.commands.deduplicate_rules import deduplicate_rules -from degree.models import Degree, program_code_to_name +from degree.models import Degree, program_code_to_name, add_course, from degree.utils.degreeworks_client import DegreeworksClient from degree.utils.parse_degreeworks import parse_and_save_degreeworks diff --git a/backend/degree/migrations/0012_alter_coursetaken_course.py b/backend/degree/migrations/0012_alter_coursetaken_course.py new file mode 100644 index 000000000..456292aba --- /dev/null +++ b/backend/degree/migrations/0012_alter_coursetaken_course.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.24 on 2024-03-28 05:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0066_course_credits'), + ('degree', '0011_alter_coursetaken_course'), + ] + + operations = [ + migrations.AlterField( + model_name='coursetaken', + name='course', + field=models.ForeignKey(help_text='\nThe full-code (e.g. CIS-1210) of the course taken\n', on_delete=django.db.models.deletion.CASCADE, to='courses.course'), + ), + ] diff --git a/backend/degree/migrations/0013_alter_coursetaken_course.py b/backend/degree/migrations/0013_alter_coursetaken_course.py new file mode 100644 index 000000000..dc60e1369 --- /dev/null +++ b/backend/degree/migrations/0013_alter_coursetaken_course.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.24 on 2024-03-28 06:33 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0066_course_credits'), + ('degree', '0012_alter_coursetaken_course'), + ] + + operations = [ + migrations.AlterField( + model_name='coursetaken', + name='course', + field=models.ForeignKey(help_text='\nThe course ID of the course taken\n', on_delete=django.db.models.deletion.CASCADE, to='courses.course'), + ), + ] diff --git a/backend/degree/models.py b/backend/degree/models.py index e769779af..cc12f0d87 100644 --- a/backend/degree/models.py +++ b/backend/degree/models.py @@ -26,36 +26,6 @@ program_code_to_name = dict(program_choices) -""" -Transcript Model -================== - -The user optionally uploads a pdf of transcript and we create a Transcript object based on the scraped transcript info. - -""" - -class Transcript(models.Model): - program = models.CharField( - max_length=255, - db_index=True, - help_text=dedent( - """ - The user's current program (e.g. SEAS B.S.) - """ - ), - ) - courses = models.ForeignKey( - Course, - null=True, - blank=True, - on_delete=models.SET_NULL, - help_text=dedent( - """ - Courses already taken, prob a list of Course objects - """ - ), - ) - class Degree(models.Model): """ This model represents a degree for a specific year. @@ -557,6 +527,32 @@ class Meta: ) ] + +class Transcript(models.Model): + """ + Not currently implemented + """ + program = models.CharField( + max_length=255, + db_index=True, + help_text=dedent( + """ + The user's current program (e.g. SEAS B.S.) + """ + ), + ) + courses = models.ForeignKey( + Course, + null=True, + blank=True, + on_delete=models.SET_NULL, + help_text=dedent( + """ + Courses already taken, prob a list of Course objects + """ + ), + ) + class DegreeProfile(models.Model): user_profile = models.OneToOneField( UserProfile, @@ -627,27 +623,28 @@ def calculate_total_credits(self): return total_credits - def add_course(self, course, semester, grade): + def add_course(self, course_id, semester, grade): """ Adds a course to courses taken """ + course_instance = Course.objects.get(id=course_id) CourseTaken.objects.create( degree_profile=self, - course=course, + course=course_instance, semester=semester, grade=grade ) - def remove_course(self, course, semester): + def remove_course(self, course_id, semester): """ Removes a course taken by a specific person """ - course_taken = CourseTaken.objects.filter(degree_profile=self, course=course, semester=semester) + course_taken = CourseTaken.objects.filter(degree_profile=self, course=course_id, semester=semester) if course_taken.exists(): course_taken.delete() else: - print(f"Course not found for course code {course} in semester {semester}.") + print(f"Course not found for course id {course_id} in semester {semester}.") class Meta: constraints = [ @@ -677,7 +674,7 @@ class CourseTaken(models.Model): on_delete=models.CASCADE, help_text=dedent( """ - The course code (e.g. CIS-1210) of the course taken + The course object of the course taken """ ), ) diff --git a/backend/degree/serializers.py b/backend/degree/serializers.py index 813a89c56..3540d597b 100644 --- a/backend/degree/serializers.py +++ b/backend/degree/serializers.py @@ -5,7 +5,7 @@ from courses.models import Course from courses.serializers import CourseListSerializer, CourseDetailSerializer -from degree.models import Degree, DegreePlan, DoubleCountRestriction, Fulfillment, Rule, DockedCourse, CourseTaken, DegreeProfile +from degree.models import Degree, DegreePlan, DoubleCountRestriction, Fulfillment, Rule, DockedCourse, CourseTaken, DegreeProfile, UserProfile from courses.util import get_current_semester class DegreeListSerializer(serializers.ModelSerializer): @@ -188,7 +188,7 @@ class Meta: class CourseTakenSerializer(serializers.ModelSerializer): - course = SimpleCourseSerializer(read_only=True) + course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all()) class Meta: model = CourseTaken @@ -198,27 +198,42 @@ class DegreeSerializer(serializers.ModelSerializer): class Meta: model = Degree fields = "__all__" + + def validate_degrees(self, degrees): + if not all(Degree.objects.filter(id=degree_id).exists() for degree_id in degrees): + raise serializers.ValidationError("Degree(s) not valid") + return degrees class DegreeProfileSerializer(serializers.ModelSerializer): - id = serializers.ReadOnlyField(help_text="The id of the user profile") - courses_taken = CourseTakenSerializer(many=True) - degrees = DegreeSerializer(many=True) + id = serializers.ReadOnlyField(help_text="The id of the degree profile") + courses_taken = CourseTakenSerializer(source='coursetaken_set', many=True, required=False) + degrees = DegreeSerializer(many=True, required=False) class Meta: model = DegreeProfile - fields = '__all__' + fields = ['id', 'user_profile', 'graduation_date', 'degrees', 'courses_taken'] def create(self, data): - degrees = data.pop('degrees') - profile = DegreeProfile.objects.create(**data) - for degree in degrees: - Degree.objects.create(degree_profile=profile, **degree) - return profile - - def update(self, instance, data): - degrees = data.pop('degrees') - instance.degrees.clear() - for degree in degrees: - Degree.objects.create(degree_profile=instance, **degree) - return super().update(instance, data) - \ No newline at end of file + degrees_data = data.pop('degrees', []) + courses_taken_data = data.pop('courses_taken', []) + + user_profile_id = data.pop('user_profile') + user_profile = UserProfile.objects.get(id=user_profile_id) + + degree_profile = DegreeProfile.objects.create(user_profile=user_profile, **data) + + if degrees_data: + degrees = Degree.objects.filter(id__in=degrees_data) + degree_profile.degrees.set(degrees) + + for course_taken_data in courses_taken_data: + CourseTaken.objects.create(degree_profile=degree_profile, **course_taken_data) + + return degree_profile + +class DegreeProfilePatchSerializer(serializers.ModelSerializer): + degrees = serializers.PrimaryKeyRelatedField(queryset=Degree.objects.all(), many=True) + + class Meta: + model = DegreeProfile + fields = ['degrees', 'graduation_date'] \ No newline at end of file diff --git a/backend/degree/views.py b/backend/degree/views.py index ae388da23..4dd961614 100644 --- a/backend/degree/views.py +++ b/backend/degree/views.py @@ -11,7 +11,7 @@ from courses.models import Course from courses.serializers import CourseListSerializer -from degree.models import Degree, DegreePlan, Fulfillment, Rule, DockedCourse, DegreeProfile +from degree.models import Degree, DegreePlan, Fulfillment, Rule, DockedCourse, DegreeProfile, UserProfile from degree.serializers import ( DegreeDetailSerializer, DegreeListSerializer, @@ -19,7 +19,9 @@ DegreePlanListSerializer, FulfillmentSerializer, DockedCourseSerializer, - DegreeProfileSerializer + DegreeProfileSerializer, + CourseTakenSerializer, + DegreeProfilePatchSerializer ) @@ -224,15 +226,62 @@ class DegreeProfileViewset(viewsets.ModelViewSet): def get_queryset(self): return DegreeProfile.objects.filter(user_profile__user=self.request.user) + + def get_serializer_class(self): + if self.request.method == 'PATCH': + return DegreeProfilePatchSerializer + return super().get_serializer_class() def perform_create(self, serializer): - return serializer.save(user_profile=self.request.user.user_profile) + user_profile, _ = UserProfile.objects.get_or_create(user=self.request.user) + return serializer.save(user_profile=user_profile.id) + + def update(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) def destroy(self, request, *args, **kwargs): instance = self.get_object() if instance.user_profile.user != request.user: - raise ValidationError({"user_profile": "Unable to delete profile"}) + return Response( + {"detail": "Unable to delete profile."}, + status=status.HTTP_403_FORBIDDEN + ) return super().destroy(request, *args, **kwargs) + + + @action(detail=True, methods=['post'], url_path='add-course', url_name='add_course') + def add_course(self, request, pk=None): + degree_profile = self.get_object() + serializer = CourseTakenSerializer(data=request.data) + if serializer.is_valid(): + degree_profile = self.get_object() + degree_profile.add_course( + course_id=serializer.validated_data['course'].id, + semester=serializer.validated_data['semester'], + grade=serializer.validated_data['grade'] + ) + return Response({'status': 'course added'}, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=['post'], url_path='remove-course', url_name='remove_course') + def remove_course(self, request, pk=None): + degree_profile = self.get_object() + course_id = request.data.get('course') + semester = request.data.get('semester') + + if not course_id or not semester: + return Response({'error': 'missing course id or semester'}, status=status.HTTP_400_BAD_REQUEST) + + try: + degree_profile.remove_course(course_id=course_id, semester=semester) + return Response({'status': 'course removed'}, status=status.HTTP_204_NO_CONTENT) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/tests/degree/test_api.py b/backend/tests/degree/test_api.py index 20d99573f..ac27fe223 100644 --- a/backend/tests/degree/test_api.py +++ b/backend/tests/degree/test_api.py @@ -5,6 +5,7 @@ from courses.models import User from courses.serializers import CourseListSerializer +from degree.serializers import DegreeSerializer, CourseTakenSerializer from courses.util import get_or_create_course_and_section from degree.models import ( Degree, @@ -358,6 +359,21 @@ def test_list_after_update(self): class DegreeProfileViewsetTest(TestCase): + def assertSerializedDegreeProfileEquals(self, degreeprofile: dict, expected: DegreeProfile): + self.assertEqual(len(degreeprofile), 5) + self.assertEqual(degreeprofile["user_profile"], expected.user_profile.id) + self.assertEqual(degreeprofile["graduation_date"], expected.graduation_date) + + expected_degrees = DegreeSerializer(expected.degrees.all(), many=True).data + expected_courses_taken = CourseTakenSerializer(expected.coursetaken_set.all(), many=True).data + + self.assertEqual( + degreeprofile["degrees"], expected_degrees + ) + self.assertEqual( + degreeprofile["courses_taken"], expected_courses_taken + ) + def setUp(self): self.user = User.objects.create_user( username="ashley", password="hi", email="hi@example.com" @@ -381,19 +397,120 @@ def setUp(self): ) self.degree_profile.degrees.set([self.degree]) - CourseTaken.objects.create(degree_profile=self.degree_profile, course=self.cis_1200, semester=TEST_SEMESTER, grade="A+") CourseTaken.objects.create(degree_profile=self.degree_profile, course=self.cis_1600, semester=TEST_SEMESTER, grade="A+") self.client = APIClient() self.client.force_login(self.user) def test_get_queryset(self): - # Authenticate as user1 self.client.force_authenticate(user=self.user) + response = self.client.get(reverse( + 'degreeprofile-detail', + kwargs={"pk": self.degree_profile.id}, + ) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['user_profile'], self.user_profile.id) + + def test_retrieve_degree_profile(self): + new_user = User.objects.create_user( + username="freshman", password="password", email="freshman@gmail.com" + ) + self.client.force_authenticate(user=new_user) - # Make a request to the viewset - response = self.client.get(reverse('degreeprofile-list')) # Adjust 'degreeprofile-list' based on your actual URL name + new_user_profile, _ = UserProfile.objects.get_or_create( + user=new_user, + defaults={'email': new_user.email, 'push_notifications': False} + ) + + new_degree_profile = DegreeProfile.objects.create( + user_profile=new_user_profile, + graduation_date="2027A", + ) + new_degree_profile.degrees.set([self.degree]) + + response = self.client.get( + reverse( + "degreeprofile-detail", + kwargs={"pk": new_degree_profile.id}, + ) + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertSerializedDegreeProfileEquals(response.data, new_degree_profile) + + def test_update_degrees(self): + """ + Replaces degrees with ones included in request + """ + self.client.force_authenticate(user=self.user) + new_degree = Degree.objects.create(program="EU_BSE", degree="BS", major="MEAM", year=2023, credits=37) + update_data = { + "degrees": [new_degree.id], + } - # Check that the response contains only user1's DegreeProfile - self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['user_profile'], self.user_profile.id) \ No newline at end of file + response = self.client.patch( + reverse("degreeprofile-detail", kwargs={"pk": self.degree_profile.id}), + data=update_data, + format='json' + ) + + self.assertEqual(response.status_code, 200, response.content) + self.degree_profile.refresh_from_db() + updated_degrees = list(self.degree_profile.degrees.values_list('id', flat=True)) + self.assertEqual(updated_degrees, [new_degree.id]) + + def test_add_course(self): + self.client.force_authenticate(user=self.user) + add_course_data = { + "course": self.cis_1200.id, + "semester": TEST_SEMESTER, + "grade": "A" + } + + response = self.client.post( + reverse("degreeprofile-add_course", kwargs={"pk": self.degree_profile.id}), + data=add_course_data, + format='json' + ) + + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue( + CourseTaken.objects.filter( + degree_profile=self.degree_profile, + course=self.cis_1200.id, + semester=TEST_SEMESTER, + grade="A" + ).exists() + ) + + def test_remove_course(self): + self.client.force_authenticate(user=self.user) + CourseTaken.objects.create( + degree_profile=self.degree_profile, + course=self.cis_1600, + semester="2024A", + grade="F" + ) + + remove_course_data = { + "course": self.cis_1600.id, + "semester": "2024A" + } + + response = self.client.post( + reverse("degreeprofile-remove_course", kwargs={"pk": self.degree_profile.id}), + data=remove_course_data, + format='json' + ) + + self.assertEqual(response.status_code, 204, response.data) + self.assertFalse( + CourseTaken.objects.filter( + degree_profile=self.degree_profile, + course=self.cis_1600.id, + semester="2024A" + ).exists() + ) + + diff --git a/backend/tests/degree/test_models.py b/backend/tests/degree/test_models.py index c02cf2fc0..ed67a2f3c 100644 --- a/backend/tests/degree/test_models.py +++ b/backend/tests/degree/test_models.py @@ -243,11 +243,11 @@ def test_add_course(self): self.cis_3200, self.cis_3200_001, _, _ = get_or_create_course_and_section( "CIS-3200-001", TEST_SEMESTER, course_defaults={"credits": 1} ) - self.degree_profile.add_course(self.cis_3200, TEST_SEMESTER, "A+") + self.degree_profile.add_course(self.cis_3200.id, TEST_SEMESTER, "A+") self.assertTrue(CourseTaken.objects.filter( degree_profile=self.degree_profile, - course=self.cis_3200, + course=self.cis_3200.id, semester=TEST_SEMESTER).exists()) def test_calculate_credits(self): @@ -258,10 +258,10 @@ def test_remove_course(self): self.cis_1210, self.cis_1210_001, _, _ = get_or_create_course_and_section( "CIS-1210-001", TEST_SEMESTER, course_defaults={"credits": 1} ) - self.degree_profile.add_course(self.cis_1210, TEST_SEMESTER, "A+") - self.degree_profile.remove_course(self.cis_1210, TEST_SEMESTER) + self.degree_profile.add_course(self.cis_1210.id, TEST_SEMESTER, "A+") + self.degree_profile.remove_course(self.cis_1210.id, TEST_SEMESTER) self.assertFalse(CourseTaken.objects.filter( degree_profile=self.degree_profile, - course=self.cis_1210, + course=self.cis_1210.id, semester=TEST_SEMESTER).exists())