From a8241c3bf4c220267cd4dbc25543e06ec36e6e98 Mon Sep 17 00:00:00 2001 From: Christopher Liu Date: Wed, 27 Mar 2024 21:12:49 -0400 Subject: [PATCH] Black --- backend/PennCourses/settings/base.py | 8 +- backend/courses/filters.py | 9 +- .../commands/recompute_soft_state.py | 10 +- .../migrations/0064_merge_20240209_2348.py | 7 +- .../migrations/0065_auto_20240211_1321.py | 41 ++- .../courses/migrations/0066_course_credits.py | 19 +- backend/courses/models.py | 2 +- backend/courses/serializers.py | 1 + backend/degree/admin.py | 11 +- backend/degree/migrations/0001_initial.py | 318 +++++++++++++++--- .../0002_alter_degreeplan_degrees.py | 12 +- .../0003_alter_fulfillment_unique_together.py | 6 +- ...04_remove_fulfillment_historical_course.py | 6 +- .../degree/migrations/0005_degree_credits.py | 13 +- .../migrations/0006_auto_20240229_1903.py | 35 +- .../0007_alter_dockedcourse_full_code.py | 13 +- backend/degree/serializers.py | 44 ++- backend/degree/urls.py | 12 +- backend/degree/utils/model_utils.py | 33 +- backend/degree/utils/parse_degreeworks.py | 2 +- backend/degree/views.py | 42 +-- 21 files changed, 478 insertions(+), 166 deletions(-) diff --git a/backend/PennCourses/settings/base.py b/backend/PennCourses/settings/base.py index 041472e92..06b7e9ce0 100644 --- a/backend/PennCourses/settings/base.py +++ b/backend/PennCourses/settings/base.py @@ -201,10 +201,10 @@ "rest_framework.authentication.BasicAuthentication", "accounts.authentication.PlatformAuthentication", ], - 'DEFAULT_PARSER_CLASSES': ( - 'rest_framework.parsers.JSONParser', - 'rest_framework.parsers.FormParser', - 'rest_framework.parsers.MultiPartParser', + "DEFAULT_PARSER_CLASSES": ( + "rest_framework.parsers.JSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", ), } diff --git a/backend/courses/filters.py b/backend/courses/filters.py index 8557e837e..7234d267e 100644 --- a/backend/courses/filters.py +++ b/backend/courses/filters.py @@ -313,16 +313,17 @@ def filter_choices(queryset, choices): return filter_choices + def degree_rules_filter(queryset, rule_ids): """ :param queryset: initial Course object queryset :param rule_ids: Comma separated string of of Rule ids to filter by. If the rule does not - have a q object, it does not filter the queryset. + have a q object, it does not filter the queryset. """ if not rule_ids: return queryset query = Q() - for rule_id in rule_ids.split(","): + for rule_id in rule_ids.split(","): try: rule = Rule.objects.get(id=int(rule_id)) except Rule.DoesNotExist | ValueError: @@ -366,7 +367,7 @@ def filter_queryset(self, request, queryset, view): if len(meeting_query) > 0: queryset = meeting_filter(queryset, meeting_query) - return queryset.distinct("full_code") # TODO: THIS IS A BREAKING CHANGE FOR PCX + return queryset.distinct("full_code") # TODO: THIS IS A BREAKING CHANGE FOR PCX def get_schema_operation_parameters(self, view): return [ @@ -379,7 +380,7 @@ def get_schema_operation_parameters(self, view): "a string of comma-separated Rule ids. If multiple Rule ids " "are passed then filtered courses satisfy all the rules." ), - "schema": {"type": "string"} + "schema": {"type": "string"}, }, { "name": "type", diff --git a/backend/courses/management/commands/recompute_soft_state.py b/backend/courses/management/commands/recompute_soft_state.py index 4b071db52..26abaf9d4 100644 --- a/backend/courses/management/commands/recompute_soft_state.py +++ b/backend/courses/management/commands/recompute_soft_state.py @@ -98,8 +98,10 @@ def recompute_enrollment(): """ ) + # course credits = sum(section credis for all activities) -COURSE_CREDITS_RAW_SQL = dedent(""" +COURSE_CREDITS_RAW_SQL = dedent( + """ WITH CourseCredits AS ( SELECT U0."id", SUM(U2."activity_cus") AS total_credits FROM "courses_course" U0 @@ -119,13 +121,15 @@ def recompute_enrollment(): """ ) + def recompute_course_credits( - model=Course # so this function can be used in migrations (see django.db.migrations.RunPython) - ): + model=Course, # so this function can be used in migrations (see django.db.migrations.RunPython) +): with connection.cursor() as cursor: cursor.execute(COURSE_CREDITS_RAW_SQL) + def recompute_precomputed_fields(verbose=False): """ Recomputes the following precomputed fields: diff --git a/backend/courses/migrations/0064_merge_20240209_2348.py b/backend/courses/migrations/0064_merge_20240209_2348.py index 50ca47417..41927dd1f 100644 --- a/backend/courses/migrations/0064_merge_20240209_2348.py +++ b/backend/courses/migrations/0064_merge_20240209_2348.py @@ -6,9 +6,8 @@ class Migration(migrations.Migration): dependencies = [ - ('courses', '0061_merge_20231112_1524'), - ('courses', '0063_auto_20231212_1750'), + ("courses", "0061_merge_20231112_1524"), + ("courses", "0063_auto_20231212_1750"), ] - operations = [ - ] + operations = [] diff --git a/backend/courses/migrations/0065_auto_20240211_1321.py b/backend/courses/migrations/0065_auto_20240211_1321.py index 209ca31eb..01a095254 100644 --- a/backend/courses/migrations/0065_auto_20240211_1321.py +++ b/backend/courses/migrations/0065_auto_20240211_1321.py @@ -6,18 +6,45 @@ class Migration(migrations.Migration): dependencies = [ - ('courses', '0064_merge_20240209_2348'), + ("courses", "0064_merge_20240209_2348"), ] operations = [ migrations.AlterField( - model_name='course', - name='num_activities', - field=models.IntegerField(default=0, help_text='\nThe number of distinct activities belonging to this course (precomputed for efficiency).\nMaintained by the registrar import / recomputestats script.\n'), + model_name="course", + name="num_activities", + field=models.IntegerField( + default=0, + help_text="\nThe number of distinct activities belonging to this course (precomputed for efficiency).\nMaintained by the registrar import / recomputestats script.\n", + ), ), migrations.AlterField( - model_name='section', - name='activity', - field=models.CharField(choices=[('', 'Undefined'), ('CLN', 'Clinic'), ('CRT', 'Clinical Rotation'), ('DAB', 'Dissertation Abroad'), ('DIS', 'Dissertation'), ('DPC', 'Doctoral Program Exchange'), ('FLD', 'Field Work'), ('HYB', 'Hybrid'), ('IND', 'Independent Study'), ('LAB', 'Lab'), ('LEC', 'Lecture'), ('MST', 'Masters Thesis'), ('ONL', 'Online'), ('PRC', 'Practicum'), ('REC', 'Recitation'), ('SEM', 'Seminar'), ('SRT', 'Senior Thesis'), ('STU', 'Studio')], db_index=True, help_text='The section activity, e.g. `LEC` for CIS-120-001 (2020A). Options and meanings:
"""Undefined"
"CLN""Clinic"
"CRT""Clinical Rotation"
"DAB""Dissertation Abroad"
"DIS""Dissertation"
"DPC""Doctoral Program Exchange"
"FLD""Field Work"
"HYB""Hybrid"
"IND""Independent Study"
"LAB""Lab"
"LEC""Lecture"
"MST""Masters Thesis"
"ONL""Online"
"PRC""Practicum"
"REC""Recitation"
"SEM""Seminar"
"SRT""Senior Thesis"
"STU""Studio"
', max_length=50), + model_name="section", + name="activity", + field=models.CharField( + choices=[ + ("", "Undefined"), + ("CLN", "Clinic"), + ("CRT", "Clinical Rotation"), + ("DAB", "Dissertation Abroad"), + ("DIS", "Dissertation"), + ("DPC", "Doctoral Program Exchange"), + ("FLD", "Field Work"), + ("HYB", "Hybrid"), + ("IND", "Independent Study"), + ("LAB", "Lab"), + ("LEC", "Lecture"), + ("MST", "Masters Thesis"), + ("ONL", "Online"), + ("PRC", "Practicum"), + ("REC", "Recitation"), + ("SEM", "Seminar"), + ("SRT", "Senior Thesis"), + ("STU", "Studio"), + ], + db_index=True, + help_text='The section activity, e.g. `LEC` for CIS-120-001 (2020A). Options and meanings:
"""Undefined"
"CLN""Clinic"
"CRT""Clinical Rotation"
"DAB""Dissertation Abroad"
"DIS""Dissertation"
"DPC""Doctoral Program Exchange"
"FLD""Field Work"
"HYB""Hybrid"
"IND""Independent Study"
"LAB""Lab"
"LEC""Lecture"
"MST""Masters Thesis"
"ONL""Online"
"PRC""Practicum"
"REC""Recitation"
"SEM""Seminar"
"SRT""Senior Thesis"
"STU""Studio"
', + max_length=50, + ), ), ] diff --git a/backend/courses/migrations/0066_course_credits.py b/backend/courses/migrations/0066_course_credits.py index ddfb7dd71..c77b47406 100644 --- a/backend/courses/migrations/0066_course_credits.py +++ b/backend/courses/migrations/0066_course_credits.py @@ -7,16 +7,23 @@ class Migration(migrations.Migration): dependencies = [ - ('courses', '0065_auto_20240211_1321'), + ("courses", "0065_auto_20240211_1321"), ] operations = [ migrations.AddField( - model_name='course', - name='credits', - field=models.DecimalField(blank=True, db_index=True, decimal_places=2, help_text='The number of credits this course takes. This is precomputed for efficiency.', max_digits=4, null=True), + model_name="course", + name="credits", + field=models.DecimalField( + blank=True, + db_index=True, + decimal_places=2, + help_text="The number of credits this course takes. This is precomputed for efficiency.", + max_digits=4, + null=True, + ), ), migrations.RunSQL( COURSE_CREDITS_RAW_SQL, - ) - ] \ No newline at end of file + ), + ] diff --git a/backend/courses/models.py b/backend/courses/models.py index 553b73fcf..27a281d3d 100644 --- a/backend/courses/models.py +++ b/backend/courses/models.py @@ -212,7 +212,7 @@ class Course(models.Model): db_index=True, help_text="The number of credits this course takes. This is precomputed for efficiency.", ) - + prerequisites = models.TextField( blank=True, help_text="Text describing the prereqs for a course, e.g. 'CIS 120, 160' for CIS-121.", diff --git a/backend/courses/serializers.py b/backend/courses/serializers.py index 69fd0f0db..cbdd3fd82 100644 --- a/backend/courses/serializers.py +++ b/backend/courses/serializers.py @@ -287,6 +287,7 @@ class Meta: ] read_only_fields = fields + class CourseDetailSerializer(CourseListSerializer): crosslistings = serializers.SlugRelatedField( slug_field="full_code", diff --git a/backend/degree/admin.py b/backend/degree/admin.py index 7d136fde9..21266a375 100644 --- a/backend/degree/admin.py +++ b/backend/degree/admin.py @@ -4,7 +4,14 @@ from django.urls import reverse from django.utils.html import format_html -from degree.models import Degree, DegreePlan, DoubleCountRestriction, Rule, SatisfactionStatus, Fulfillment +from degree.models import ( + Degree, + DegreePlan, + DoubleCountRestriction, + Rule, + SatisfactionStatus, + Fulfillment, +) # Register your models here. @@ -18,10 +25,12 @@ class RuleAdmin(admin.ModelAdmin): admin.site.register(DegreePlan) admin.site.register(SatisfactionStatus) + @admin.register(Fulfillment) class FulfillmentAdmin(admin.ModelAdmin): autocomplete_fields = ["rules"] + @admin.register(DoubleCountRestriction) class DoubleCountRestrictionAdmin(admin.ModelAdmin): autocomplete_fields = ["rule", "other_rule"] diff --git a/backend/degree/migrations/0001_initial.py b/backend/degree/migrations/0001_initial.py index 10098e3a4..94fc107e8 100644 --- a/backend/degree/migrations/0001_initial.py +++ b/backend/degree/migrations/0001_initial.py @@ -11,91 +11,307 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('courses', '0061_merge_20231112_1524'), + ("courses", "0061_merge_20231112_1524"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Degree', + name="Degree", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('program', models.CharField(choices=[('EU_BSE', 'Engineering BSE'), ('EU_BAS', 'Engineering BAS'), ('AU_BA', 'College BA'), ('WU_BS', 'Wharton BS')], help_text='\nThe program code for this degree, e.g., EU_BSE\n', max_length=10)), - ('degree', models.CharField(help_text='\nThe degree code for this degree, e.g., BSE\n', max_length=4)), - ('major', models.CharField(help_text='\nThe major code for this degree, e.g., BIOL\n', max_length=4)), - ('concentration', models.CharField(help_text='\nThe concentration code for this degree, e.g., BMAT\n', max_length=4, null=True)), - ('year', models.IntegerField(help_text='\nThe effective year of this degree, e.g., 2023\n')), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "program", + models.CharField( + choices=[ + ("EU_BSE", "Engineering BSE"), + ("EU_BAS", "Engineering BAS"), + ("AU_BA", "College BA"), + ("WU_BS", "Wharton BS"), + ], + help_text="\nThe program code for this degree, e.g., EU_BSE\n", + max_length=10, + ), + ), + ( + "degree", + models.CharField( + help_text="\nThe degree code for this degree, e.g., BSE\n", max_length=4 + ), + ), + ( + "major", + models.CharField( + help_text="\nThe major code for this degree, e.g., BIOL\n", max_length=4 + ), + ), + ( + "concentration", + models.CharField( + help_text="\nThe concentration code for this degree, e.g., BMAT\n", + max_length=4, + null=True, + ), + ), + ( + "year", + models.IntegerField( + help_text="\nThe effective year of this degree, e.g., 2023\n" + ), + ), ], ), migrations.CreateModel( - name='DegreePlan', + name="DegreePlan", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text="The user's nickname for the degree plan.", max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('degrees', models.ManyToManyField(help_text='The degrees this degree plan is associated with.', to='degree.Degree')), - ('person', models.ForeignKey(help_text='The user the degree plan belongs to.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + help_text="The user's nickname for the degree plan.", max_length=255 + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "degrees", + models.ManyToManyField( + help_text="The degrees this degree plan is associated with.", + to="degree.Degree", + ), + ), + ( + "person", + models.ForeignKey( + help_text="The user the degree plan belongs to.", + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='Rule', + name="Rule", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(blank=True, help_text='\nThe title for this rule.\n', max_length=200)), - ('num', models.PositiveSmallIntegerField(help_text='\nThe minimum number of courses or subrules required for this rule.\n', null=True)), - ('credits', models.DecimalField(decimal_places=2, help_text='\nThe minimum number of CUs required for this rule. Only non-null\nif this is a Rule leaf.\n', max_digits=4, null=True)), - ('q', models.TextField(blank=True, help_text='\nString representing a Q() object that returns the set of courses\nsatisfying this rule. Non-empty iff this is a Rule leaf.\nThis Q object is expected to be normalized before it is serialized\nto a string.\n', max_length=1000)), - ('parent', models.ForeignKey(help_text="\nThis rule's parent Rule if it has one. Null if this is a top level rule\n(i.e., this rule belongs to some Degree's `.rules` set).\n", null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='degree.rule')), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "title", + models.CharField( + blank=True, help_text="\nThe title for this rule.\n", max_length=200 + ), + ), + ( + "num", + models.PositiveSmallIntegerField( + help_text="\nThe minimum number of courses or subrules required for this rule.\n", + null=True, + ), + ), + ( + "credits", + models.DecimalField( + decimal_places=2, + help_text="\nThe minimum number of CUs required for this rule. Only non-null\nif this is a Rule leaf.\n", + max_digits=4, + null=True, + ), + ), + ( + "q", + models.TextField( + blank=True, + help_text="\nString representing a Q() object that returns the set of courses\nsatisfying this rule. Non-empty iff this is a Rule leaf.\nThis Q object is expected to be normalized before it is serialized\nto a string.\n", + max_length=1000, + ), + ), + ( + "parent", + models.ForeignKey( + help_text="\nThis rule's parent Rule if it has one. Null if this is a top level rule\n(i.e., this rule belongs to some Degree's `.rules` set).\n", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="degree.rule", + ), + ), ], ), migrations.CreateModel( - name='SatisfactionStatus', + name="SatisfactionStatus", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('satisfied', models.BooleanField(default=False, help_text='Whether the rule is satisfied')), - ('last_updated', models.DateTimeField(auto_now=True)), - ('last_checked', models.DateTimeField(default=django.utils.timezone.now)), - ('degree_plan', models.ForeignKey(help_text='The degree plan that leads to the satisfaction of the rule', on_delete=django.db.models.deletion.CASCADE, related_name='satisfactions', to='degree.degreeplan')), - ('rule', models.ForeignKey(help_text='The rule that is satisfied', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='degree.rule')), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "satisfied", + models.BooleanField(default=False, help_text="Whether the rule is satisfied"), + ), + ("last_updated", models.DateTimeField(auto_now=True)), + ("last_checked", models.DateTimeField(default=django.utils.timezone.now)), + ( + "degree_plan", + models.ForeignKey( + help_text="The degree plan that leads to the satisfaction of the rule", + on_delete=django.db.models.deletion.CASCADE, + related_name="satisfactions", + to="degree.degreeplan", + ), + ), + ( + "rule", + models.ForeignKey( + help_text="The rule that is satisfied", + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="degree.rule", + ), + ), ], ), migrations.CreateModel( - name='Fulfillment', + name="Fulfillment", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('full_code', models.CharField(blank=True, db_index=True, help_text='The dash-joined department and code of the course, e.g., `CIS-120`', max_length=16)), - ('semester', models.CharField(help_text='\nThe semester of the course (of the form YYYYx where x is A [for spring],\nB [summer], or C [fall]), e.g. `2019C` for fall 2019. Null if this fulfillment\ndoes not yet have a semester.\n', max_length=5, null=True)), - ('degree_plan', models.ForeignKey(help_text='The degree plan with which this fulfillment is associated', on_delete=django.db.models.deletion.CASCADE, related_name='fulfillments', to='degree.degreeplan')), - ('historical_course', models.ForeignKey(help_text='\nThe last offering of the course with the full code, or null if\nthere is no such historical course.\n', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='courses.course')), - ('rules', models.ManyToManyField(blank=True, help_text='\nThe rules this course fulfills. Blank if this course does not apply\nto any rules.\n', related_name='_degree_fulfillment_rules_+', to='degree.Rule')), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "full_code", + models.CharField( + blank=True, + db_index=True, + help_text="The dash-joined department and code of the course, e.g., `CIS-120`", + max_length=16, + ), + ), + ( + "semester", + models.CharField( + help_text="\nThe semester of the course (of the form YYYYx where x is A [for spring],\nB [summer], or C [fall]), e.g. `2019C` for fall 2019. Null if this fulfillment\ndoes not yet have a semester.\n", + max_length=5, + null=True, + ), + ), + ( + "degree_plan", + models.ForeignKey( + help_text="The degree plan with which this fulfillment is associated", + on_delete=django.db.models.deletion.CASCADE, + related_name="fulfillments", + to="degree.degreeplan", + ), + ), + ( + "historical_course", + models.ForeignKey( + help_text="\nThe last offering of the course with the full code, or null if\nthere is no such historical course.\n", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="courses.course", + ), + ), + ( + "rules", + models.ManyToManyField( + blank=True, + help_text="\nThe rules this course fulfills. Blank if this course does not apply\nto any rules.\n", + related_name="_degree_fulfillment_rules_+", + to="degree.Rule", + ), + ), ], ), migrations.CreateModel( - name='DoubleCountRestriction', + name="DoubleCountRestriction", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('max_courses', models.PositiveSmallIntegerField(help_text='\nThe maximum number of courses you can count for both rules.\nIf null, there is no limit, and max_credits must not be null.\n', null=True)), - ('max_credits', models.DecimalField(decimal_places=2, help_text='\nThe maximum number of CUs you can count for both rules.\nIf null, there is no limit, and max_credits must not be null.\n', max_digits=4, null=True)), - ('other_rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='degree.rule')), - ('rule', models.ForeignKey(help_text='\nA rule in the double count restriction.\n', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='degree.rule')), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "max_courses", + models.PositiveSmallIntegerField( + help_text="\nThe maximum number of courses you can count for both rules.\nIf null, there is no limit, and max_credits must not be null.\n", + null=True, + ), + ), + ( + "max_credits", + models.DecimalField( + decimal_places=2, + help_text="\nThe maximum number of CUs you can count for both rules.\nIf null, there is no limit, and max_credits must not be null.\n", + max_digits=4, + null=True, + ), + ), + ( + "other_rule", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="degree.rule", + ), + ), + ( + "rule", + models.ForeignKey( + help_text="\nA rule in the double count restriction.\n", + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="degree.rule", + ), + ), ], ), migrations.AddField( - model_name='degree', - name='rules', - field=models.ManyToManyField(blank=True, help_text='\nThe rules for this degree. Blank if this degree has no rules.\n', related_name='degrees', to='degree.Rule'), + model_name="degree", + name="rules", + field=models.ManyToManyField( + blank=True, + help_text="\nThe rules for this degree. Blank if this degree has no rules.\n", + related_name="degrees", + to="degree.Rule", + ), ), migrations.AddConstraint( - model_name='satisfactionstatus', - constraint=models.UniqueConstraint(fields=('degree_plan', 'rule'), name='unique_satisfaction'), + model_name="satisfactionstatus", + constraint=models.UniqueConstraint( + fields=("degree_plan", "rule"), name="unique_satisfaction" + ), ), migrations.AddConstraint( - model_name='degreeplan', - constraint=models.UniqueConstraint(fields=('name', 'person'), name='degreeplan_name_person'), + model_name="degreeplan", + constraint=models.UniqueConstraint( + fields=("name", "person"), name="degreeplan_name_person" + ), ), migrations.AddConstraint( - model_name='degree', - constraint=models.UniqueConstraint(fields=('program', 'degree', 'major', 'concentration', 'year'), name='unique degree'), + model_name="degree", + constraint=models.UniqueConstraint( + fields=("program", "degree", "major", "concentration", "year"), name="unique degree" + ), ), ] diff --git a/backend/degree/migrations/0002_alter_degreeplan_degrees.py b/backend/degree/migrations/0002_alter_degreeplan_degrees.py index 6e7b4f918..68cbdd9a5 100644 --- a/backend/degree/migrations/0002_alter_degreeplan_degrees.py +++ b/backend/degree/migrations/0002_alter_degreeplan_degrees.py @@ -6,13 +6,17 @@ class Migration(migrations.Migration): dependencies = [ - ('degree', '0001_initial'), + ("degree", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='degreeplan', - name='degrees', - field=models.ManyToManyField(blank=True, help_text='The degrees this degree plan is associated with.', to='degree.Degree'), + model_name="degreeplan", + name="degrees", + field=models.ManyToManyField( + blank=True, + help_text="The degrees this degree plan is associated with.", + to="degree.Degree", + ), ), ] diff --git a/backend/degree/migrations/0003_alter_fulfillment_unique_together.py b/backend/degree/migrations/0003_alter_fulfillment_unique_together.py index a0a26d221..11865cfff 100644 --- a/backend/degree/migrations/0003_alter_fulfillment_unique_together.py +++ b/backend/degree/migrations/0003_alter_fulfillment_unique_together.py @@ -6,12 +6,12 @@ class Migration(migrations.Migration): dependencies = [ - ('degree', '0002_alter_degreeplan_degrees'), + ("degree", "0002_alter_degreeplan_degrees"), ] operations = [ migrations.AlterUniqueTogether( - name='fulfillment', - unique_together={('degree_plan', 'full_code')}, + name="fulfillment", + unique_together={("degree_plan", "full_code")}, ), ] diff --git a/backend/degree/migrations/0004_remove_fulfillment_historical_course.py b/backend/degree/migrations/0004_remove_fulfillment_historical_course.py index efaaf59f6..5f8e0ba99 100644 --- a/backend/degree/migrations/0004_remove_fulfillment_historical_course.py +++ b/backend/degree/migrations/0004_remove_fulfillment_historical_course.py @@ -6,12 +6,12 @@ class Migration(migrations.Migration): dependencies = [ - ('degree', '0003_alter_fulfillment_unique_together'), + ("degree", "0003_alter_fulfillment_unique_together"), ] operations = [ migrations.RemoveField( - model_name='fulfillment', - name='historical_course', + model_name="fulfillment", + name="historical_course", ), ] diff --git a/backend/degree/migrations/0005_degree_credits.py b/backend/degree/migrations/0005_degree_credits.py index cd4bf7ae5..c3e78dcf4 100644 --- a/backend/degree/migrations/0005_degree_credits.py +++ b/backend/degree/migrations/0005_degree_credits.py @@ -6,14 +6,19 @@ class Migration(migrations.Migration): dependencies = [ - ('degree', '0004_remove_fulfillment_historical_course'), + ("degree", "0004_remove_fulfillment_historical_course"), ] operations = [ migrations.AddField( - model_name='degree', - name='credits', - field=models.DecimalField(decimal_places=2, default=32, help_text='\nThe minimum number of CUs required for this degree.\n', max_digits=4), + model_name="degree", + name="credits", + field=models.DecimalField( + decimal_places=2, + default=32, + help_text="\nThe minimum number of CUs required for this degree.\n", + max_digits=4, + ), preserve_default=False, ), ] diff --git a/backend/degree/migrations/0006_auto_20240229_1903.py b/backend/degree/migrations/0006_auto_20240229_1903.py index 015131bf7..0ff27adfe 100644 --- a/backend/degree/migrations/0006_auto_20240229_1903.py +++ b/backend/degree/migrations/0006_auto_20240229_1903.py @@ -9,20 +9,41 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('degree', '0005_degree_credits'), + ("degree", "0005_degree_credits"), ] operations = [ migrations.CreateModel( - name='DockedCourse', + name="DockedCourse", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('full_code', models.CharField(blank=True, help_text='The dash-joined department and code of the course, e.g., `CIS-120`', max_length=16)), - ('person', models.ForeignKey(help_text='The user the docked course belongs to.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "full_code", + models.CharField( + blank=True, + help_text="The dash-joined department and code of the course, e.g., `CIS-120`", + max_length=16, + ), + ), + ( + "person", + models.ForeignKey( + help_text="The user the docked course belongs to.", + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.AddConstraint( - model_name='dockedcourse', - constraint=models.UniqueConstraint(fields=('person', 'full_code'), name='unique docked course'), + model_name="dockedcourse", + constraint=models.UniqueConstraint( + fields=("person", "full_code"), name="unique docked course" + ), ), ] diff --git a/backend/degree/migrations/0007_alter_dockedcourse_full_code.py b/backend/degree/migrations/0007_alter_dockedcourse_full_code.py index a1af594fc..783c49ff6 100644 --- a/backend/degree/migrations/0007_alter_dockedcourse_full_code.py +++ b/backend/degree/migrations/0007_alter_dockedcourse_full_code.py @@ -6,13 +6,18 @@ class Migration(migrations.Migration): dependencies = [ - ('degree', '0006_auto_20240229_1903'), + ("degree", "0006_auto_20240229_1903"), ] operations = [ migrations.AlterField( - model_name='dockedcourse', - name='full_code', - field=models.CharField(blank=True, db_index=True, help_text='The dash-joined department and code of the course, e.g., `CIS-120`', max_length=16), + model_name="dockedcourse", + name="full_code", + field=models.CharField( + blank=True, + db_index=True, + help_text="The dash-joined department and code of the course, e.g., `CIS-120`", + max_length=16, + ), ), ] diff --git a/backend/degree/serializers.py b/backend/degree/serializers.py index cbffca7ca..4472170bf 100644 --- a/backend/degree/serializers.py +++ b/backend/degree/serializers.py @@ -5,14 +5,23 @@ from courses.models import Course from courses.serializers import CourseListSerializer, CourseDetailSerializer -from degree.models import Degree, DegreePlan, DoubleCountRestriction, Fulfillment, Rule, DockedCourse +from degree.models import ( + Degree, + DegreePlan, + DoubleCountRestriction, + Fulfillment, + Rule, + DockedCourse, +) from courses.util import get_current_semester + class DegreeListSerializer(serializers.ModelSerializer): class Meta: model = Degree fields = "__all__" + class SimpleCourseSerializer(serializers.ModelSerializer): id = serializers.ReadOnlyField( source="full_code", @@ -24,19 +33,19 @@ class SimpleCourseSerializer(serializers.ModelSerializer): ) course_quality = serializers.DecimalField( - max_digits=4, decimal_places=3, read_only=True, help_text='course_quality_help' + max_digits=4, decimal_places=3, read_only=True, help_text="course_quality_help" ) difficulty = serializers.DecimalField( - max_digits=4, decimal_places=3, read_only=True, help_text='difficulty_help' + max_digits=4, decimal_places=3, read_only=True, help_text="difficulty_help" ) instructor_quality = serializers.DecimalField( max_digits=4, decimal_places=3, read_only=True, - help_text='instructor_quality_help', + help_text="instructor_quality_help", ) work_required = serializers.DecimalField( - max_digits=4, decimal_places=3, read_only=True, help_text='work_required_help' + max_digits=4, decimal_places=3, read_only=True, help_text="work_required_help" ) class Meta: @@ -53,13 +62,14 @@ class Meta: ] read_only_fields = fields + class RuleSerializer(serializers.ModelSerializer): q_json = serializers.ReadOnlyField(help_text="JSON representation of the q object") class Meta: model = Rule fields = "__all__" - + def to_representation(self, instance): data = super(RuleSerializer, self).to_representation(instance) data.q = "" @@ -89,14 +99,23 @@ class Meta: class FulfillmentSerializer(serializers.ModelSerializer): course = serializers.SerializerMethodField() + def get_course(self, obj): - course = Course.with_reviews.filter(full_code=obj.full_code, semester__lte=get_current_semester()).order_by("-semester").first() + course = ( + Course.with_reviews.filter( + full_code=obj.full_code, semester__lte=get_current_semester() + ) + .order_by("-semester") + .first() + ) if course is not None: return SimpleCourseSerializer(course).data return None - + # TODO: add a get_queryset method to only allow rules from the degree plan - rules = serializers.PrimaryKeyRelatedField(many=True, queryset=Rule.objects.all(), required=False) + rules = serializers.PrimaryKeyRelatedField( + many=True, queryset=Rule.objects.all(), required=False + ) def to_internal_value(self, data): data = data.copy() @@ -149,7 +168,7 @@ class DegreePlanListSerializer(serializers.ModelSerializer): # degree_ids = serializers.PrimaryKeyRelatedField( # many=True, - # required=False, + # required=False, # source="degrees", # queryset=Degree.objects.all(), # help_text="The degree_id this degree plan belongs to.", @@ -167,8 +186,7 @@ class DegreePlanDetailSerializer(serializers.ModelSerializer): # help_text="The courses used to fulfill degree plan.", # ) degrees = DegreeDetailSerializer( - many=True, - help_text="The degrees belonging to this degree plan" + many=True, help_text="The degrees belonging to this degree plan" ) person = serializers.HiddenField(default=serializers.CurrentUserDefault()) @@ -184,4 +202,4 @@ class DockedCourseSerializer(serializers.ModelSerializer): class Meta: model = DockedCourse - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/backend/degree/urls.py b/backend/degree/urls.py index c09e4c3f9..3169af441 100644 --- a/backend/degree/urls.py +++ b/backend/degree/urls.py @@ -2,14 +2,22 @@ from rest_framework.routers import DefaultRouter from rest_framework_nested.routers import NestedDefaultRouter -from degree.views import DegreePlanViewset, DegreeViewset, FulfillmentViewSet, courses_for_rule, DockedCourseViewset +from degree.views import ( + DegreePlanViewset, + DegreeViewset, + FulfillmentViewSet, + courses_for_rule, + DockedCourseViewset, +) router = DefaultRouter(trailing_slash=False) router.register(r"degreeplans", DegreePlanViewset, basename="degreeplan") router.register(r"degrees", DegreeViewset, basename="degree") router.register(r"docked", DockedCourseViewset) -fulfillments_router = NestedDefaultRouter(router, r"degreeplans", lookup="degreeplan", trailing_slash=False) +fulfillments_router = NestedDefaultRouter( + router, r"degreeplans", lookup="degreeplan", trailing_slash=False +) fulfillments_router.register(r"fulfillments", FulfillmentViewSet, basename="degreeplan-fulfillment") urlpatterns = [ diff --git a/backend/degree/utils/model_utils.py b/backend/degree/utils/model_utils.py index 6e0af177b..189ad4cb5 100644 --- a/backend/degree/utils/model_utils.py +++ b/backend/degree/utils/model_utils.py @@ -68,6 +68,7 @@ def q(self, n): (clause,) = n return clause + class JSONTransformer(Transformer): """ This class transforms the Abstract Syntax Tree (AST) generated by the parser @@ -83,36 +84,20 @@ def array(self, n): def and_clause(self, clauses): if len(clauses) == 1: return clauses[0] - return { - 'type': 'AND', - 'clauses': clauses - } + return {"type": "AND", "clauses": clauses} def or_clause(self, clauses): - return { - 'type': 'OR', - 'clauses': clauses - } + return {"type": "OR", "clauses": clauses} def not_clause(self, clauses): - return { - 'type': 'NOT', - 'clauses': clauses - } + return {"type": "NOT", "clauses": clauses} def condition(self, n): key, value = n - if key == 'full_code': - return { - 'type': 'COURSE', - 'full_code': value - } - else: - return { - 'type': 'LEAF', - 'key': key, - 'value': value - } + if key == "full_code": + return {"type": "COURSE", "full_code": value} + else: + return {"type": "LEAF", "key": key, "value": value} def string(self, s): (s,) = s @@ -183,4 +168,4 @@ def q(self, n): start="q", transformer=JSONTransformer(), parser="lalr", -) \ No newline at end of file +) diff --git a/backend/degree/utils/parse_degreeworks.py b/backend/degree/utils/parse_degreeworks.py index 88835a371..8c9c7d16c 100644 --- a/backend/degree/utils/parse_degreeworks.py +++ b/backend/degree/utils/parse_degreeworks.py @@ -300,7 +300,7 @@ def parse_degreeworks(json: dict, degree: Degree) -> list[Rule] | None: # check if this requirement actually has anything in it if degree_req == rules[-1] and not degree_req.q: rules.pop() - + # special case for Additional majors if degree.credits is None: logging.error("Skipped degree because it has not total credits requirement.") diff --git a/backend/degree/views.py b/backend/degree/views.py index e0223c289..b5af8d024 100644 --- a/backend/degree/views.py +++ b/backend/degree/views.py @@ -18,7 +18,7 @@ DegreePlanDetailSerializer, DegreePlanListSerializer, FulfillmentSerializer, - DockedCourseSerializer + DockedCourseSerializer, ) @@ -34,18 +34,19 @@ class DegreeViewset(viewsets.ReadOnlyModelViewSet): def get_queryset(self): queryset = Degree.objects.all() - degree_id = self.request.query_params.get('id', None) + degree_id = self.request.query_params.get("id", None) if degree_id is not None: queryset = queryset.filter(id=degree_id) return queryset - + def get_serializer_class(self): if self.action == "list": - if self.request.query_params.get('id', None) is not None: + if self.request.query_params.get("id", None) is not None: return DegreeDetailSerializer return DegreeListSerializer return DegreeDetailSerializer + class DegreePlanViewset(AutoPrefetchViewSetMixin, viewsets.ModelViewSet): """ List, retrieve, create, destroy, and update a DegreePlan. @@ -72,7 +73,7 @@ def get_serializer_context(self): context = super().get_serializer_context() context.update({"request": self.request}) # used to get the user return context - + def retrieve(self, request, *args, **kwargs): degree_plan = self.get_object() serializer = self.get_serializer(degree_plan) @@ -80,32 +81,31 @@ def retrieve(self, request, *args, **kwargs): def create(self, request, *args, **kwargs): if request.data.get("name") is None: - raise ValidationError({ "name": "This field is required." }) + raise ValidationError({"name": "This field is required."}) new_degree_plan = DegreePlan(name=request.data.get("name"), person=self.request.user) new_degree_plan.save() serializer = self.get_serializer(new_degree_plan) return Response(serializer.data, status=status.HTTP_201_CREATED) - @action(detail=True, methods=["post"]) def copy(self, request, pk=None): """ Copy a degree plan. """ if request.data.get("name") is None: - raise ValidationError({ "name": "This field is required." }) + raise ValidationError({"name": "This field is required."}) degree_plan = self.get_object() new_degree_plan = degree_plan.copy(request.data["name"]) serializer = self.get_serializer(new_degree_plan) return Response(serializer.data, status=status.HTTP_201_CREATED) - + @action(detail=True, methods=["post", "delete"]) def degrees(self, request, pk=None): degree_ids = request.data.get("degree_ids") if not isinstance(degree_ids, list): raise ValidationError({"degree_ids": "This field must be a list."}) if degree_ids is None: - raise ValidationError({ "degree_ids": "This field is required." }) + raise ValidationError({"degree_ids": "This field is required."}) degree_plan = self.get_object() try: @@ -151,16 +151,17 @@ def get_queryset(self): degree_plan_id=self.get_degree_plan_id(), ) return queryset - + def create(self, request, *args, **kwargs): if request.data.get("full_code") is None: - raise ValidationError({ "full_code": "This field is required." }) + raise ValidationError({"full_code": "This field is required."}) self.kwargs["full_code"] = request.data["full_code"] try: return self.partial_update(request, *args, **kwargs) except Http404: return super().create(request, *args, **kwargs) - + + @api_view(["GET"]) def courses_for_rule(request, rule_id: int): """ @@ -172,6 +173,7 @@ class DockedCourseViewset(viewsets.ModelViewSet): """ List, retrieve, create, destroy, and update docked courses """ + permission_classes = [IsAuthenticated] serializer_class = DockedCourseSerializer # http_method_names = ["get", "post", "head", "delete"] @@ -186,32 +188,32 @@ def get_serializer_context(self): context = super().get_serializer_context() context.update({"request": self.request}) # used to get the user return context - + # def retrieve(self, request, *args, **kwargs): # dockedCourse = self.get_object() # serializer = self.get_serializer(dockedCourse) # return Response(serializer.data, status=status.HTTP_200_OK) - + def create(self, request, *args, **kwargs): if request.data.get("full_code") is None: - raise ValidationError({ "full_code": "This field is required." }) + raise ValidationError({"full_code": "This field is required."}) self.kwargs["full_code"] = request.data["full_code"] self.kwargs["person"] = self.request.user try: return self.partial_update(request, *args, **kwargs) except Http404: return super().create(request, *args, **kwargs) - + def destroy(self, request, *args, **kwargs): if kwargs["full_code"] is None: - raise ValidationError({ "full_code": "This field is required." }) + raise ValidationError({"full_code": "This field is required."}) instances_to_delete = self.get_queryset().filter(full_code=kwargs["full_code"]) - + if not instances_to_delete.exists(): raise Http404("No instances matching the provided full_code were found.") for instance in instances_to_delete: self.perform_destroy(instance) - + return Response(status.HTTP_200_OK)