diff --git a/backend/degree/management/commands/fetch_degrees.py b/backend/degree/management/commands/fetch_degrees.py index 1d2816891..821ef3a1d 100644 --- a/backend/degree/management/commands/fetch_degrees.py +++ b/backend/degree/management/commands/fetch_degrees.py @@ -47,12 +47,14 @@ def add_arguments(self, parser): ) def handle(self, *args, **kwargs): - print(dedent( - """ + print( + dedent( + """ Note: this script deletes any existing degres in the database that overlap with the degrees fetched from degreeworks. """ - )) + ) + ) since_year = kwargs["since_year"] to_year = kwargs["to_year"] or int(get_current_semester()[:4]) diff --git a/backend/degree/management/commands/load_degrees.py b/backend/degree/management/commands/load_degrees.py index c96171fdc..82ebce209 100644 --- a/backend/degree/management/commands/load_degrees.py +++ b/backend/degree/management/commands/load_degrees.py @@ -36,7 +36,8 @@ def handle(self, *args, **kwargs): for degree_file in listdir(directory): year, program, degree, major, concentration = re.match( - r"(\d+)-(\w+)-(\w+)-(\w+)(?:-(\w+))?", degree_file).groups() + r"(\d+)-(\w+)-(\w+)-(\w+)(?:-(\w+))?", degree_file + ).groups() if program not in program_code_to_name: print(f"Skipping {degree_file} because {program} is not an applicable program code") continue @@ -47,7 +48,7 @@ def handle(self, *args, **kwargs): degree=degree, major=major, concentration=concentration, - year=year + year=year, ).delete() degree = Degree( diff --git a/backend/degree/migrations/0007_auto_20231213_1757.py b/backend/degree/migrations/0007_auto_20231213_1757.py index 8ffda0cbe..2e07da2eb 100644 --- a/backend/degree/migrations/0007_auto_20231213_1757.py +++ b/backend/degree/migrations/0007_auto_20231213_1757.py @@ -10,142 +10,239 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('degree', '0006_auto_20231201_1713'), + ("degree", "0006_auto_20231201_1713"), ] 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='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)), + ( + "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)), ], ), migrations.RemoveField( - model_name='userdegreeplan', - name='degree_plan', + model_name="userdegreeplan", + name="degree_plan", ), migrations.RemoveField( - model_name='userdegreeplan', - name='person', + model_name="userdegreeplan", + name="person", ), migrations.RemoveConstraint( - model_name='degreeplan', - name='unique degreeplan', + model_name="degreeplan", + name="unique degreeplan", ), migrations.RemoveField( - model_name='degreeplan', - name='concentration', + model_name="degreeplan", + name="concentration", ), migrations.RemoveField( - model_name='degreeplan', - name='major', + model_name="degreeplan", + name="major", ), migrations.RemoveField( - model_name='degreeplan', - name='program', + model_name="degreeplan", + name="program", ), migrations.RemoveField( - model_name='degreeplan', - name='year', + model_name="degreeplan", + name="year", ), migrations.RemoveField( - model_name='doublecountrestriction', - name='degree_plan', + model_name="doublecountrestriction", + name="degree_plan", ), migrations.RemoveField( - model_name='fulfillment', - name='user_degree_plan', + model_name="fulfillment", + name="user_degree_plan", ), migrations.RemoveField( - model_name='rule', - name='degree_plan', + model_name="rule", + name="degree_plan", ), migrations.AddField( - model_name='degreeplan', - name='created_at', + model_name="degreeplan", + name="created_at", field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False, ), migrations.AddField( - model_name='degreeplan', - name='name', - field=models.CharField(default='', help_text="The user's nickname for the degree plan.", max_length=255), + model_name="degreeplan", + name="name", + field=models.CharField( + default="", help_text="The user's nickname for the degree plan.", max_length=255 + ), preserve_default=False, ), migrations.AddField( - model_name='degreeplan', - name='person', - field=models.ForeignKey(default='', help_text='The user the schedule belongs to.', on_delete=django.db.models.deletion.CASCADE, to='auth.user'), + model_name="degreeplan", + name="person", + field=models.ForeignKey( + default="", + help_text="The user the schedule belongs to.", + on_delete=django.db.models.deletion.CASCADE, + to="auth.user", + ), preserve_default=False, ), migrations.AddField( - model_name='degreeplan', - name='updated_at', + model_name="degreeplan", + name="updated_at", field=models.DateTimeField(auto_now=True), ), migrations.AddField( - model_name='fulfillment', - name='degree_plan', - field=models.ForeignKey(default='', help_text='The degree plan with which this fulfillment is associated', on_delete=django.db.models.deletion.CASCADE, related_name='fulfillments', to='degree.degreeplan'), + model_name="fulfillment", + name="degree_plan", + field=models.ForeignKey( + default="", + help_text="The degree plan with which this fulfillment is associated", + on_delete=django.db.models.deletion.CASCADE, + related_name="fulfillments", + to="degree.degreeplan", + ), preserve_default=False, ), migrations.AlterField( - model_name='fulfillment', - 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="fulfillment", + 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, + ), ), migrations.AlterField( - model_name='rule', - name='parent', - field=models.ForeignKey(help_text="\nThis rule's parent Rule if it has one. Null if this is a top level rule\n(i.e., degree is not null).\n", null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='degree.rule'), + model_name="rule", + name="parent", + field=models.ForeignKey( + help_text="\nThis rule's parent Rule if it has one. Null if this is a top level rule\n(i.e., degree is not null).\n", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="degree.rule", + ), ), 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.DeleteModel( - name='UserDegreePlan', + name="UserDegreePlan", ), migrations.AddField( - model_name='satisfactionstatus', - name='degree_plan', - field=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'), + model_name="satisfactionstatus", + name="degree_plan", + field=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", + ), ), migrations.AddField( - model_name='satisfactionstatus', - name='rule', - field=models.ForeignKey(help_text='The rule that is satisfied', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='degree.rule'), + model_name="satisfactionstatus", + name="rule", + field=models.ForeignKey( + help_text="The rule that is satisfied", + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="degree.rule", + ), ), 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" + ), ), migrations.AddField( - model_name='rule', - name='degree', - field=models.ForeignKey(help_text='\nThe degree plan that has this rule. Null if this rule has a parent.\n', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rules', to='degree.degree'), + model_name="rule", + name="degree", + field=models.ForeignKey( + help_text="\nThe degree plan that has this rule. Null if this rule has a parent.\n", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="rules", + to="degree.degree", + ), ), migrations.AlterField( - model_name='degreeplan', - name='degree', - field=models.ForeignKey(help_text='The degree this is associated with.', on_delete=django.db.models.deletion.CASCADE, to='degree.degree'), + model_name="degreeplan", + name="degree", + field=models.ForeignKey( + help_text="The degree this is associated with.", + on_delete=django.db.models.deletion.CASCADE, + to="degree.degree", + ), ), 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" + ), ), ] diff --git a/backend/degree/migrations/0008_alter_rule_q.py b/backend/degree/migrations/0008_alter_rule_q.py new file mode 100644 index 000000000..0665d38ab --- /dev/null +++ b/backend/degree/migrations/0008_alter_rule_q.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.23 on 2023-12-14 21:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("degree", "0007_auto_20231213_1757"), + ] + + operations = [ + migrations.AlterField( + model_name="rule", + name="q", + field=models.TextField( + blank=True, + help_text="\nString representing a Q() object that returns the set of courses\nsatisfying this rule. Only non-empty if this is a Rule leaf.\nThis Q object is expected to be normalized before it is serialized\nto a string.\n", + max_length=1000, + ), + ), + ] diff --git a/backend/degree/models.py b/backend/degree/models.py index f2d95fe07..564d31eed 100644 --- a/backend/degree/models.py +++ b/backend/degree/models.py @@ -200,12 +200,13 @@ def evaluate(self, full_codes: Iterable[str]) -> bool: if self.num is not None and count < self.num: return False return True - + def get_q_object(self) -> Q | None: if not self.q: return None return q_object_parser.parse(self.q) + class DegreePlan(models.Model): """ Stores a users plan for an associated degree. @@ -275,7 +276,8 @@ def evaluate_rules(self, rules: list[Rule]) -> tuple[set[Rule], set[DoubleCountR violated_dcrs = set() relevant_dcrs = DoubleCountRestriction.objects.filter( - Q(rule__in=rules) | Q(other_rule__in=rules)).all() + Q(rule__in=rules) | Q(other_rule__in=rules) + ).all() for dcr in relevant_dcrs: if dcr.is_double_count_violated(self): violated_dcrs.add(dcr) @@ -288,45 +290,6 @@ def evaluate_rules(self, rules: list[Rule]) -> tuple[set[Rule], set[DoubleCountR return (satisfied_rules, violated_dcrs) - # def check_degree(self): - # """ - # Recheck the rules starting with the affected rules and moving up - # """ - # affected_rules = list(self.satisfactions.filter(last_updated__gt=F("last_checked"))) - # fulfillments = self.fulfillments.all() - # satisfactions = self.satisfactions.all() - # double_count_restrictions = self.degree.double_count_restrictions.all() - - # updated_satisfaction = set() - - # for restriction in double_count_restrictions: - # evaluation = restriction.check_double_count(self) - # for satisfaction in satisfactions.filter( - # Q(rule=restriction.rule) | Q(rule=restriction.other_rule) - # ).all(): - # satisfaction.satisfied = evaluation - # satisfaction.last_checked = timezone.now() - # satisfaction.save(update_fields=["satisfied", "last_checked"]) - # updated_satisfaction.add(satisfaction) - - # while len(affected_rules) > 0: - # rule = affected_rules.pop() - # if rule is None: - # continue - # evaluation = rule.evaluate(fulfillments) - # satisfaction = satisfactions.filter(rule=rule).get() - # if evaluation != satisfaction.satisfied: # satisfaction status changed - # affected_rules.append(rule.parent) - - # # AND the evaluation with previous state if state changed in this check - # if satisfaction in updated_satisfaction: - # satisfaction.satisfied = evaluation and satisfaction.satisfied - # else: - # satisfaction.satisfied = evaluation - - # satisfaction.last_checked = timezone.now() - # satisfaction.save(update_fields=["satisfied", "last_checked"]) - class Fulfillment(models.Model): degree_plan = models.ForeignKey( diff --git a/backend/degree/serializers.py b/backend/degree/serializers.py index 2214e11db..f61fb3fb0 100644 --- a/backend/degree/serializers.py +++ b/backend/degree/serializers.py @@ -39,9 +39,9 @@ class Meta: fields = "__all__" -class DegreePlanDetailSerializer(serializers.ModelSerializer): +class DegreeDetailSerializer(serializers.ModelSerializer): - # field to represent the rules related to this Degree Plan + # Field to represent the rules related to this Degree rules = RuleSerializer(many=True, read_only=True) double_count_restrictions = DoubleCountRestrictionSerializer(many=True, read_only=True) @@ -69,29 +69,29 @@ class Meta: fields = "__all__" -class UserDegreePlanListSerializer(serializers.ModelSerializer): - degree_plan = DegreeListSerializer(read_only=True) - id = serializers.ReadOnlyField(help_text="The id of the UserDegreePlan.") +class DegreePlanListSerializer(serializers.ModelSerializer): + degree = DegreeListSerializer(read_only=True) + id = serializers.ReadOnlyField(help_text="The id of the DegreePlan.") class Meta: model = DegreePlan - fields = ["id", "name", "degree_plan"] + fields = ["id", "name", "degree"] -class UserDegreePlanDetailSerializer(serializers.ModelSerializer): +class DegreePlanDetailSerializer(serializers.ModelSerializer): fulfillments = FulfillmentSerializer( many=True, read_only=True, help_text="The courses used to fulfill degree plan.", ) - degree_plan = DegreePlanDetailSerializer(read_only=True) - degree_plan_id = serializers.PrimaryKeyRelatedField( + degree = DegreeDetailSerializer(read_only=True) + degree_id = serializers.PrimaryKeyRelatedField( write_only=True, - source="degree_plan", + source="degree", queryset=Degree.objects.all(), - help_text="The degree plan to which this user degree plan belongs.", + help_text="The degree_id this degree plan belongs to.", ) - id = serializers.ReadOnlyField(help_text="The id of the user degree plan.") + id = serializers.ReadOnlyField(help_text="The id of the degree plan.") person = serializers.HiddenField(default=serializers.CurrentUserDefault()) class Meta: diff --git a/backend/degree/urls.py b/backend/degree/urls.py index 1cfc5df9b..a7827bfb3 100644 --- a/backend/degree/urls.py +++ b/backend/degree/urls.py @@ -1,17 +1,17 @@ from django.urls import include, path from rest_framework import routers -from degree.views import DegreeDetail, DegreeList, UserDegreePlanViewset, rule_courses +from degree.views import DegreeDetail, DegreeList, DegreePlanViewset, rule_courses router = routers.DefaultRouter() -router.register("degreeplans", UserDegreePlanViewset, basename="degreeplans") +router.register("degreeplans", DegreePlanViewset, basename="degreeplans") urlpatterns = [ path("degrees/", DegreeList.as_view(), name="degree-list"), path( - "degree_detail/", + "degree_detail/", DegreeDetail.as_view(), name="degree-detail", ), diff --git a/backend/degree/utils/parse_degreeworks.py b/backend/degree/utils/parse_degreeworks.py index c3cd17e8c..ffde00c62 100644 --- a/backend/degree/utils/parse_degreeworks.py +++ b/backend/degree/utils/parse_degreeworks.py @@ -219,13 +219,9 @@ def parse_rulearray( # add if part or else part, depending on evaluation of the condition if evaluation: - parse_rulearray( - rule_req["ifPart"]["ruleArray"], degree, rules, parent=parent - ) + parse_rulearray(rule_req["ifPart"]["ruleArray"], degree, rules, parent=parent) elif "elsePart" in rule_req: - parse_rulearray( - rule_req["elsePart"]["ruleArray"], degree, rules, parent=parent - ) + parse_rulearray(rule_req["elsePart"]["ruleArray"], degree, rules, parent=parent) case "Subset": if "ruleArray" in rule_json: parse_rulearray(rule_json["ruleArray"], degree, rules, parent=parent) diff --git a/backend/degree/views.py b/backend/degree/views.py index 6c9ba83d5..53a264a19 100644 --- a/backend/degree/views.py +++ b/backend/degree/views.py @@ -1,20 +1,18 @@ from django.core.exceptions import ObjectDoesNotExist -from django.db import IntegrityError from django_auto_prefetching import AutoPrefetchViewSetMixin -from rest_framework import generics, status, viewsets, mixins +from rest_framework import generics, status, viewsets +from rest_framework.decorators import api_view from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.decorators import api_view, permission_classes -from courses.serializers import CourseListSerializer from courses.models import Course -from degree.models import Degree, Rule, DegreePlan -from degree.utils.model_utils import q_object_parser +from courses.serializers import CourseListSerializer +from degree.models import Degree, DegreePlan, Rule from degree.serializers import ( - DegreePlanDetailSerializer, + DegreeDetailSerializer, DegreeListSerializer, - UserDegreePlanDetailSerializer, - UserDegreePlanListSerializer, + DegreePlanDetailSerializer, + DegreePlanListSerializer, ) from PennCourses.docs_settings import PcxAutoSchema @@ -48,13 +46,13 @@ class DegreeDetail(generics.RetrieveAPIView): }, ) - serializer_class = DegreePlanDetailSerializer + serializer_class = DegreeDetailSerializer queryset = Degree.objects.all() -class UserDegreePlanViewset(AutoPrefetchViewSetMixin, viewsets.ModelViewSet): +class DegreePlanViewset(AutoPrefetchViewSetMixin, viewsets.ModelViewSet): """ - list, retrieve, create, destroy, and update user degree plans. + List, retrieve, create, destroy, and update a DegreePlan. """ permission_classes = [IsAuthenticated] @@ -63,15 +61,15 @@ def get_queryset(self): queryset = DegreePlan.objects.filter(person=self.request.user) queryset = queryset.prefetch_related( "fulfillments", - "degree_plan", - "degree_plan__rules", + "degree", + "degree__rules", ) return queryset def get_serializer_class(self): if self.action == "list": - return UserDegreePlanListSerializer - return UserDegreePlanDetailSerializer + return DegreePlanListSerializer + return DegreePlanDetailSerializer def get_serializer_context(self): context = super().get_serializer_context() @@ -91,7 +89,7 @@ def rule_courses(request, rule_id: int): data={"error": f"Rule with id {rule_id} does not exist."}, status=status.HTTP_404_NOT_FOUND, ) - + q = rule.get_q_object() if q is None: return Response(