forked from pennlabs/penn-courses
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
129 additions
and
86 deletions.
There are no files selected for viewing
20 changes: 0 additions & 20 deletions
20
backend/degree/migrations/0007_alter_fulfillment_historical_course.py
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,15 @@ | ||
import React, { useCallback, useEffect } from "https://cdn.jsdelivr.net/npm/[email protected]/+esm"; | ||
import React, { | ||
useCallback, | ||
useEffect, | ||
} from "https://cdn.jsdelivr.net/npm/[email protected]/+esm"; | ||
import ReactDOM from "https://cdn.jsdelivr.net/npm/[email protected]/+esm"; | ||
import ReactFlow, { | ||
ConnectionLineType, | ||
useNodesState, | ||
useEdgesState, | ||
Controls, | ||
Background, | ||
Panel | ||
Panel, | ||
} from "https://cdn.jsdelivr.net/npm/[email protected]/+esm"; | ||
import dagre from "https://esm.sh/[email protected]"; | ||
|
||
|
@@ -16,32 +19,29 @@ const params = new Proxy(new URLSearchParams(window.location.search), { | |
const id = Number(params.id); | ||
const renderRule = (rule) => { | ||
return ( | ||
<div style={{display: "flex", flexDirection: "column", gap: ".5em"}}> | ||
<div style={{ display: "flex", flexDirection: "column", gap: ".5em" }}> | ||
<div>{rule.id}</div> | ||
<div style={{ fontWeight: "bold" }}>{rule.title || "<No title>"}</div> | ||
<div>Q: {rule.q}</div> | ||
<div>Num: {rule.num}</div> | ||
<div>Credits: {rule.credits}</div> | ||
</div> | ||
) | ||
); | ||
}; | ||
|
||
const nodeWidth = 172; | ||
const nodeHeight = 300; | ||
const dagreGraph = new dagre.graphlib.Graph(); | ||
dagreGraph.setDefaultEdgeLabel(() => ({})); | ||
const getLayoutedElements = (nodes, edges, direction = 'TB') => { | ||
const isHorizontal = direction === 'LR'; | ||
const getLayoutedElements = (nodes, edges, direction = "TB") => { | ||
const isHorizontal = direction === "LR"; | ||
dagreGraph.setGraph({ rankdir: direction }); | ||
|
||
nodes.forEach((node) => { | ||
dagreGraph.setNode( | ||
node.id, | ||
{ | ||
width: nodeWidth, | ||
height: nodeHeight | ||
} | ||
); | ||
dagreGraph.setNode(node.id, { | ||
width: nodeWidth, | ||
height: nodeHeight, | ||
}); | ||
}); | ||
|
||
edges.forEach((edge) => { | ||
|
@@ -52,8 +52,8 @@ const getLayoutedElements = (nodes, edges, direction = 'TB') => { | |
|
||
nodes.forEach((node) => { | ||
const nodeWithPosition = dagreGraph.node(node.id); | ||
node.targetPosition = isHorizontal ? 'left' : 'top'; | ||
node.sourcePosition = isHorizontal ? 'right' : 'bottom'; | ||
node.targetPosition = isHorizontal ? "left" : "top"; | ||
node.sourcePosition = isHorizontal ? "right" : "bottom"; | ||
|
||
// We are shifting the dagre node position (anchor=center center) to the top left | ||
// so it matches the React Flow node anchor point (top left). | ||
|
@@ -68,50 +68,50 @@ const getLayoutedElements = (nodes, edges, direction = 'TB') => { | |
return { nodes, edges }; | ||
}; | ||
|
||
const pkOfNodeId = (nodeId) => [nodeId.startsWith("d"), Number(nodeId.slice(1))] | ||
const pkOfNodeId = (nodeId) => [ | ||
nodeId.startsWith("d"), | ||
Number(nodeId.slice(1)), | ||
]; | ||
|
||
const LayoutFlow = () => { | ||
const [nodes, setNodes, onNodesChange] = useNodesState([]); | ||
const [edges, setEdges, onEdgesChange] = useEdgesState([]); | ||
|
||
const onConnect = useCallback( | ||
(params) => { | ||
console.log("onConnect", params) | ||
if (params.source === params.target) return; | ||
const [sourceIsDegree, sourceId] = pkOfNodeId(params.source); | ||
const [targetIsDegree, targetId] = pkOfNodeId(params.target); | ||
if (sourceIsDegree || targetIsDegree) return; | ||
console.log("HERE") | ||
const redirect = `/admin/degree/doublecountrestriction/add/?rule=${sourceId}&other_rule=${targetId}`; | ||
window.location.href = redirect; | ||
}, | ||
[] | ||
); | ||
|
||
const onEdgeDelete = useCallback( | ||
(edge) => { | ||
if (!edge.id.startsWith("c")) return; | ||
}, | ||
[] | ||
) | ||
|
||
const onConnect = useCallback((params) => { | ||
console.log("onConnect", params); | ||
if (params.source === params.target) return; | ||
const [sourceIsDegree, sourceId] = pkOfNodeId(params.source); | ||
const [targetIsDegree, targetId] = pkOfNodeId(params.target); | ||
if (sourceIsDegree || targetIsDegree) return; | ||
console.log("HERE"); | ||
const redirect = `/admin/degree/doublecountrestriction/add/?rule=${sourceId}&other_rule=${targetId}`; | ||
window.location.href = redirect; | ||
}, []); | ||
|
||
const onEdgeDelete = useCallback((edge) => { | ||
if (!edge.id.startsWith("c")) return; | ||
}, []); | ||
|
||
useEffect(() => { | ||
const fetchDegree = async () => { | ||
if (!id) return; | ||
const degree = await fetch(`/api/degree/degrees/${id}/`).then(response => response.json()); | ||
|
||
const degree = await fetch(`/api/degree/degrees/${id}/`).then( | ||
(response) => response.json() | ||
); | ||
|
||
// get the nodes: the rules + the top level node | ||
const root = { | ||
id: "d" + degree.id, | ||
type: "default", | ||
const root = { | ||
id: "d" + degree.id, | ||
type: "default", | ||
width: 300, | ||
data: { label: `${degree.program} ${degree.degree} in ${degree.major} with conc. ${degree.concentration} (${degree.year})` }, | ||
data: { | ||
label: `${degree.program} ${degree.degree} in ${degree.major} with conc. ${degree.concentration} (${degree.year})`, | ||
}, | ||
style: { | ||
background: "lightblue" | ||
} | ||
background: "lightblue", | ||
}, | ||
}; | ||
|
||
const nodes = []; | ||
const edges = []; | ||
const stack = degree.rules.slice(); | ||
|
@@ -121,43 +121,45 @@ const LayoutFlow = () => { | |
nodes.push({ | ||
id, | ||
type: "default", | ||
data: { | ||
data: { | ||
label: renderRule(rule), | ||
}, | ||
position: { x: 0, y: 0}, | ||
position: { x: 0, y: 0 }, | ||
width: 300, | ||
}); | ||
const source = rule.parent ? `r${rule.parent}` : `d${degree.id}` | ||
const source = rule.parent ? `r${rule.parent}` : `d${degree.id}`; | ||
if (source) { | ||
edges.push({ | ||
source: source, | ||
edges.push({ | ||
source: source, | ||
target: id, | ||
id: `e${source.id}-${id}`, | ||
type: "smoothstep", | ||
}) | ||
}; | ||
}); | ||
} | ||
rule.rules.forEach((subrule) => stack.push(subrule)); | ||
} | ||
|
||
for (const doubleCountRestriction of degree.double_count_restrictions || []) { | ||
for (const doubleCountRestriction of degree.double_count_restrictions || | ||
[]) { | ||
const source = `r${doubleCountRestriction.rule}`; | ||
const target = `r${doubleCountRestriction.other_rule}`; | ||
edges.push({ | ||
source, | ||
edges.push({ | ||
source, | ||
target, | ||
id: `c${source}-${target}`, | ||
type: "smoothstep", | ||
animated: true, | ||
style: { stroke: "red" } | ||
style: { stroke: "red" }, | ||
}); | ||
} | ||
|
||
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements([root, ...nodes], edges); | ||
const { nodes: layoutedNodes, edges: layoutedEdges } = | ||
getLayoutedElements([root, ...nodes], edges); | ||
setNodes(layoutedNodes); | ||
setEdges(layoutedEdges); | ||
} | ||
}; | ||
fetchDegree(); | ||
}, []) | ||
}, []); | ||
|
||
return ( | ||
<ReactFlow | ||
|
@@ -173,10 +175,20 @@ const LayoutFlow = () => { | |
> | ||
<Controls /> | ||
<Background variant="dots" gap={50} size={1} /> | ||
<Panel position="top-right" style={{ display: "flex", flexDirection: "column", gap: ".5em", padding: "1em", backgroundColor: "rgba(0, 0, 0, 0.4)"}}> | ||
<a href={`${window.location.pathname}?id=${id+1}`}>Next Degree</a> | ||
{ id > 1 && <a href={`${window.location.pathname}?id=${id-1}`}>Prev Degree</a> | ||
} | ||
<Panel | ||
position="top-right" | ||
style={{ | ||
display: "flex", | ||
flexDirection: "column", | ||
gap: ".5em", | ||
padding: "1em", | ||
backgroundColor: "rgba(0, 0, 0, 0.4)", | ||
}} | ||
> | ||
<a href={`${window.location.pathname}?id=${id + 1}`}>Next Degree</a> | ||
{id > 1 && ( | ||
<a href={`${window.location.pathname}?id=${id - 1}`}>Prev Degree</a> | ||
)} | ||
</Panel> | ||
</ReactFlow> | ||
); | ||
|
@@ -190,5 +202,4 @@ const App = () => { | |
); | ||
}; | ||
|
||
ReactDOM.render(<App />, document.getElementById('app')); | ||
|
||
ReactDOM.render(<App />, document.getElementById("app")); |