Skip to content

Commit

Permalink
test rule evaluations
Browse files Browse the repository at this point in the history
  • Loading branch information
AaDalal committed Oct 13, 2023
1 parent a61bcc1 commit 7e7c0eb
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 23 deletions.
61 changes: 39 additions & 22 deletions backend/degree/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from textwrap import dedent
from typing import Iterable
from courses.models import Course
from django.db.models import Count, Sum
from django.db.models import Count, Sum, Q, DecimalField
from django.db.models.functions import Coalesce

from degree.utils.model_utils import q_object_parser

Expand All @@ -12,7 +13,7 @@ class DegreePlan(models.Model):
"""

program = models.CharField(
max_length=32,
max_length=10,
choices=[
("EU_BSE", "Engineering BSE"),
("EU_BAS", "Engineering BAS"),
Expand All @@ -26,23 +27,23 @@ class DegreePlan(models.Model):
),
)
degree = models.CharField(
max_length=32,
max_length=4,
help_text=dedent(
"""
The degree code for this degree plan, e.g., BSE
"""
),
)
major = models.CharField(
max_length=32,
max_length=4,
help_text=dedent(
"""
The major code for this degree plan, e.g., BIOL
"""
),
)
concentration = models.CharField(
max_length=32,
max_length=4,
null=True,
help_text=dedent(
"""
Expand All @@ -67,7 +68,7 @@ class Rule(models.Model):
This model represents a degree requirement rule.
"""

num_courses = models.IntegerField(
num_courses = models.PositiveSmallIntegerField(
null=True,
help_text=dedent(
"""
Expand All @@ -78,13 +79,13 @@ class Rule(models.Model):
)

credits = models.DecimalField(
decimal_places=1,
decimal_places=2,
max_digits=4,
null=True,
help_text=dedent(
"""
The minimum number of CUs required for this rule. Only non-null
if this is a Rule leaf.
if this is a Rule leaf. Can be
"""
),
)
Expand All @@ -104,7 +105,9 @@ class Rule(models.Model):
help_text=dedent(
"""
String representing a Q() object that returns the set of courses
satisfying this rule. Only non-empty if this is a Rule leaf.
satisfying this rule. Only non-null/non-empty if this is a Rule leaf.
This Q object is expected to be normalized before it is serialized
to a string.
"""
),
)
Expand All @@ -121,6 +124,14 @@ class Rule(models.Model):
related_name="children"
)

class Meta:
constraints = [
models.CheckConstraint(check=(
(Q(credits__isnull=True) | Q(credits__gt=0)) # check credits and num are non-zero
& (Q(num_courses__isnull=True) | Q(num_courses__gt=0))
), name="num_course_credits_gt_0")
]

def __str__(self) -> str:
return f"{self.q}, num={self.num_courses}, cus={self.credits}, degree_plan={self.degree_plan}"

Expand All @@ -130,26 +141,32 @@ def evaluate(self, full_codes: Iterable[str]) -> bool:
Check if this rule is fulfilled by the provided
courses.
"""
if self.q is not None:
# TODO: remove in prod code?
if self.q:
assert not self.children.all().exists()
fulfillments = Course.objects.filter(
total_courses, total_credits = Course.objects.filter(
q_object_parser.parse(self.q),
id__in=full_codes
).annotate(
num_courses=Count(),
credits=Sum()
)
fulfillment = fulfillment.get()

if fulfillment.num_courses < self.num_courses or fulfillment.credits < self.credits:
full_code__in=full_codes
).aggregate(
total_courses=Count("id"),
total_credits=Coalesce(
Sum("credits"), 0,
output_field=DecimalField(max_digits=4, decimal_places=2)
)
).values()

assert self.num_courses is not None or self.credits is not None
if self.num_courses is not None and total_courses < self.num_courses:
return False

if self.credits is not None and total_credits < self.credits:
return False

# run some extra checks...
# TODO: run some extra checks...

return True

assert self.children.all().exists()
for child in self.children.all():
if not child.evaluate():
if not child.evaluate(full_codes):
return False
return True
105 changes: 104 additions & 1 deletion backend/tests/degree/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
from degree.utils.model_utils import q_object_parser
from django.db.models import Q
from lark.exceptions import LarkError
from degree.models import Rule, DegreePlan
from courses.util import get_or_create_course_and_section

TEST_SEMESTER = "2023C"

class QObjectParserTest(TestCase):
def assertParsedEqual(self, q: Q):
Expand Down Expand Up @@ -74,6 +78,105 @@ def test_unparseable_value(self):

def test_idempotency(self):
self.assertParsedEqual(q_object_parser.parse(repr(Q(key="\"'value"))))



class RuleEvaluationTest(TestCase):
def setUp(self):
self.cis_1200, self.cis_1200_001, _, _ = get_or_create_course_and_section(
"CIS-1200-001",
TEST_SEMESTER,
course_defaults={"credits": 1}
)
self.cis_1600, self.cis_1600_001, _, _ = get_or_create_course_and_section(
"CIS-1600-001",
TEST_SEMESTER,
course_defaults={"credits": 1}
)
self.cis_1600, self.cis_1600_001, _, _ = get_or_create_course_and_section(
"CIS-1600-001",
TEST_SEMESTER,
course_defaults={"credits": 1}
)
self.cis_1910, self.cis_1910_001, _, _ = get_or_create_course_and_section(
"CIS-1910-001",
TEST_SEMESTER,
course_defaults={"credits": 0.5}
)

self.degree_plan = DegreePlan.objects.create(
program="EU_BSE",
degree="BSE",
major="CIS",
year=2023
)
self.parent_rule = Rule.objects.create(degree_plan=self.degree_plan)
self.rule1 = Rule.objects.create(
degree_plan=self.degree_plan,
parent=self.parent_rule,
q=repr(Q(full_code="CIS-1200")),
num_courses=1
)
self.rule2 = Rule.objects.create( # Self-contradictory rule
degree_plan=self.degree_plan,
parent=None, # For now...
q=repr(Q(full_code__startswith="CIS-12", full_code__endswith="1600")),
credits=1
)
self.rule3 = Rule.objects.create( # .5 cus / 1 course CIS-19XX classes
degree_plan=self.degree_plan,
parent=self.parent_rule,
q=repr(Q(full_code__startswith="CIS-19")),
credits=.5,
num_courses=1
)
self.rule4 = Rule.objects.create( # 2 CIS classes
degree_plan=self.degree_plan,
parent=None,
q=repr(Q(full_code__startswith="CIS")),
num_courses=2
)

def test_satisfied_rule(self):
self.assertTrue(self.rule1.evaluate([self.cis_1200.full_code]))

def test_satisfied_multiple_courses(self):
self.assertTrue(self.rule4.evaluate([self.cis_1600.full_code, self.cis_1200.full_code]))
self.assertTrue(self.rule4.evaluate([self.cis_1910.full_code, self.cis_1200.full_code]))

def test_satisfied_rule_num_courses_credits(self):
self.assertTrue(self.rule3.evaluate([self.cis_1910.full_code]))

def test_surpass_rule(self):
self.assertTrue(self.rule4.evaluate([self.cis_1200.full_code, self.cis_1910.full_code, self.cis_1600.full_code]))

def test_unsatisfied_rule(self):
self.assertFalse(self.rule1.evaluate([self.cis_1600.full_code]))

def test_unsatisfiable_rule(self):
# rule2 is self-contradicting
self.assertFalse(self.rule2.evaluate([self.cis_1200.full_code, self.cis_1600.full_code]))

def test_nonexistant_course(self):
# CIS-1857 doesn't exist
self.assertFalse(self.rule4.evaluate("CIS-1857"))


def test_unsatisfied_rule_num_courses(self):
self.assertFalse(self.rule4.evaluate([self.cis_1200.full_code]))

def test_unsatisfied_rule_credits(self):
self.rule3.credits = 1.5
self.rule3.save()
self.assertFalse(self.rule3.evaluate([self.cis_1910.full_code]))

def test_unsatisfied_rule_num_courses_credits(self):
self.rule3.credits = 1.5
self.rule3.num_courses = 2
self.rule3.save()
self.assertFalse(self.rule3.evaluate([self.cis_1910.full_code]))

def test_parent_rule_unsatisfied(self):
self.assertFalse(self.parent_rule.evaluate([self.cis_1200.full_code]))

def test_parent_rule_satisfied(self):
self.assertTrue(self.parent_rule.evaluate([self.cis_1200.full_code, self.cis_1910.full_code]))

0 comments on commit 7e7c0eb

Please sign in to comment.