Skip to content

Commit

Permalink
Merge pull request #21 from pennlabs/degree-editor
Browse files Browse the repository at this point in the history
Degree editor
  • Loading branch information
AaDalal authored Feb 7, 2024
2 parents 5165854 + 5f5573e commit 8fa0dc5
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 10 deletions.
48 changes: 45 additions & 3 deletions backend/degree/admin.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,53 @@
from django.conf.urls import url
from django.contrib import admin
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils.html import format_html

from degree.models import Degree, DegreePlan, DoubleCountRestriction, Rule, SatisfactionStatus


# Register your models here.
admin.site.register(Degree)
admin.site.register(Rule)
@admin.register(Rule)
class RuleAdmin(admin.ModelAdmin):
search_fields = ["title", "id"]
list_display = ["title", "id", "parent"]
list_select_related = ["parent"]


admin.site.register(DegreePlan)
admin.site.register(SatisfactionStatus)
admin.site.register(DoubleCountRestriction)


@admin.register(DoubleCountRestriction)
class DoubleCountRestrictionAdmin(admin.ModelAdmin):
autocomplete_fields = ["rule", "other_rule"]


@admin.register(Degree)
class DegreeAdmin(admin.ModelAdmin):
autocomplete_fields = ["rules"]
list_display = ["program", "degree", "major", "concentration", "year", "view_degree_editor"]

def view_degree_editor(self, obj):
return format_html(
'<a href="{url}?id={id}">View in Degree Editor</a>',
id=obj.id,
url=reverse("admin:degree-editor"),
)

def get_urls(self):
# get the default urls
urls = super().get_urls()
custom_urls = [
url(
r"^degree-editor/$",
self.admin_site.admin_view(self.degree_editor),
name="degree-editor",
)
]
return custom_urls + urls

def degree_editor(self, request):
context = dict(self.admin_site.each_context(request))
return TemplateResponse(request, "degree-editor.html", context)
2 changes: 1 addition & 1 deletion backend/degree/management/commands/deduplicate_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def deduplicate_rules(verbose=False):

class Command(BaseCommand):
help = dedent(
"""
"""
Removes rules that are identical (based on content hash)
"""
)
Expand Down
52 changes: 52 additions & 0 deletions backend/degree/migrations/0007_auto_20240207_0105.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Generated by Django 3.2.23 on 2024-02-07 06:05

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('courses', '0061_merge_20231112_1524'),
('degree', '0006_auto_20240205_1950'),
]

operations = [
migrations.AlterField(
model_name='degreeplan',
name='degrees',
field=models.ManyToManyField(help_text='The degrees this degree plan is associated with.', to='degree.Degree'),
),
migrations.AlterField(
model_name='degreeplan',
name='person',
field=models.ForeignKey(help_text='The user the degree plan belongs to.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='doublecountrestriction',
name='max_courses',
field=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),
),
migrations.AlterField(
model_name='doublecountrestriction',
name='max_credits',
field=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),
),
migrations.AlterField(
model_name='doublecountrestriction',
name='rule',
field=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.AlterField(
model_name='fulfillment',
name='historical_course',
field=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'),
),
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. 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),
),
]
39 changes: 34 additions & 5 deletions backend/degree/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ def __str__(self) -> str:
class Rule(models.Model):
"""
This model represents a degree requirement rule.
Rules are deduplicated, meaning that
a rule can belong to multiple degrees. In that case, changing a rule on one degree would
also change it on the other degrees.
"""

title = models.CharField(
Expand Down Expand Up @@ -136,7 +140,7 @@ 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. Non-empty iff this is a Rule leaf.
This Q object is expected to be normalized before it is serialized
to a string.
"""
Expand All @@ -158,7 +162,7 @@ class Rule(models.Model):

def __str__(self) -> str:
return f"{self.title}, q={self.q}, num={self.num}, cus={self.credits}, \
degree={self.degree}, parent={self.parent.title if self.parent else None}"
degrees={self.degrees.all()}, parent={self.parent.title if self.parent else None}"

def evaluate(self, full_codes: Iterable[str]) -> bool:
"""
Expand Down Expand Up @@ -217,13 +221,13 @@ class DegreePlan(models.Model):

degrees = models.ManyToManyField(
Degree,
help_text="The degree this is associated with.",
help_text="The degrees this degree plan is associated with.",
)

person = models.ForeignKey(
get_user_model(),
on_delete=models.CASCADE,
help_text="The user the schedule belongs to.",
help_text="The user the degree plan belongs to.",
)

created_at = models.DateTimeField(auto_now_add=True)
Expand Down Expand Up @@ -414,11 +418,26 @@ class Meta:


class DoubleCountRestriction(models.Model):
"""
Represents a restriction on the number of courses and credits
that can be double counted between two rules.
Note the following things:
1. this relationship is non-directional: rule and other_rule are interchangeable.
2. the same rule cannot be used for both rule and other_rule.
3. max_courses and max_credits cannot both be null.
4. since rules are can belong to multiple degrees, a double count restriction added
for one degree will affect all other degrees tje rule belongs to.
(2) and (3) are not enforced directly by the database, but are expected to be enforced
in use.
"""

max_courses = models.PositiveSmallIntegerField(
null=True,
help_text=dedent(
"""
The maximum number of courses you can count for both rules.
If null, there is no limit, and max_credits must not be null.
"""
),
)
Expand All @@ -430,11 +449,21 @@ class DoubleCountRestriction(models.Model):
help_text=dedent(
"""
The maximum number of CUs you can count for both rules.
If null, there is no limit, and max_credits must not be null.
"""
),
)

rule = models.ForeignKey(Rule, on_delete=models.CASCADE, related_name="+")
rule = models.ForeignKey(
Rule,
on_delete=models.CASCADE,
related_name="+",
help_text=dedent(
"""
A rule in the double count restriction.
"""
),
)

other_rule = models.ForeignKey(Rule, on_delete=models.CASCADE, related_name="+")

Expand Down
28 changes: 28 additions & 0 deletions backend/degree/static/pdp/degree-editor-style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.editor {
height: 100%;
width: 100%;
font-family: "Space Mono", "Arial", sans-serif;
}

.react-flow__handle-top {
background-color: blue;
}

.react-flow__handle-bottom {
background-color: red;
}

.react-flow__handle-connecting .react-flow__handle-valid {
width: 8px;
height: 8px;
}

.react-flow__handle-connecting:not(.react-flow__handle-valid) {
background-color: darkgray;
}

#content {
width: 100%;
height: 80vh;
padding: 0;
}
Loading

0 comments on commit 8fa0dc5

Please sign in to comment.