Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3b7a3c4
Process post request with mastery learning info
naveen-nathan Nov 27, 2024
6fd229c
Restore index method to original state
naveen-nathan Nov 30, 2024
a8232ab
Generate CM from post parameters
naveen-nathan Nov 30, 2024
6e875a7
Access nested student and class mastery keys
naveen-nathan Nov 30, 2024
d82e70a
Bug fix + return correct error code
naveen-nathan Dec 3, 2024
ed2f1c2
Fix validation bug + load constants from file
naveen-nathan Dec 3, 2024
1e9c7c8
Attempt to POST CM through the iframe + transfer CM-200 changes
naveen-nathan Dec 12, 2024
a51cdc5
Implement api endpoint to return mastery mapping and post to CM in if…
naveen-nathan Dec 13, 2024
8037a38
Make dedicated router for mastery mapping + cleanup
naveen-nathan Dec 13, 2024
fcd71d8
Formatting changes
naveen-nathan Dec 13, 2024
40c97fe
Formatting changes
naveen-nathan Dec 13, 2024
99d1c45
Formatting changes
naveen-nathan Dec 13, 2024
65706c5
Formatting change
naveen-nathan Dec 13, 2024
d454114
Misc. changes + optimize getTopicsFromUser
naveen-nathan Dec 19, 2024
4d22d42
Rename variables per convention
naveen-nathan Dec 19, 2024
68f296c
Nest async function within useEffect
naveen-nathan Dec 19, 2024
28e54a7
Merge branch 'main' into CM-28/process-mastery-level-POST-request
naveen-nathan Dec 19, 2024
564662b
Minor change
naveen-nathan Dec 19, 2024
307c193
Use axios to retrieve CM html
naveen-nathan Dec 19, 2024
ea8ae67
Remove error handling
naveen-nathan Dec 19, 2024
97b6586
Mark deprecated functions as such
naveen-nathan Jan 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/v2/Routes/students/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import RateLimit from 'express-rate-limit';
import GradesRouter from './grades/index.js';
import ProjectionsRouter from './projections/index.js';
import ProgressQueryStringRouter from './progressquerystring/index.js';
import MasteryMappingRouter from './masterymapping/index.js';
import { validateAdminOrStudentMiddleware } from '../../../lib/authlib.mjs';
import { validateAdminMiddleware } from '../../../lib/authlib.mjs';
import { getStudents } from '../../../lib/redisHelper.mjs';
Expand All @@ -24,6 +25,7 @@ router.use('/:email', validateAdminOrStudentMiddleware);
router.use('/:id/grades', GradesRouter);
router.use('/:email/projections', ProjectionsRouter);
router.use('/:id/progressquerystring', ProgressQueryStringRouter);
router.use('/:id/masterymapping', MasteryMappingRouter);

router.get('/', validateAdminMiddleware, async (_, res) => {
try {
Expand Down
75 changes: 75 additions & 0 deletions api/v2/Routes/students/masterymapping/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Router } from 'express';
import {
getMaxScores,
getStudentScores,
} from '../../../../lib/redisHelper.mjs';
import ProgressReportData from '../../../../assets/progressReport/CS10.json' assert { type: 'json' };

const router = Router({ mergeParams: true });

function getTopicsFromUser(gradeData) {
const topicsTable = {};
Object.entries(gradeData)
.flatMap(([assignment, topics]) => Object.entries(topics))
.forEach(([topic, score]) => {
if (topic in topicsTable) {
topicsTable[topic] += +(score ?? 0);
} else {
topicsTable[topic] = +(score ?? 0);
}
});
return topicsTable;
}

async function getMasteryMapping(userTopicPoints, maxTopicPoints) {
const numMasteryLevels = ProgressReportData['student levels'].length - 2;
Object.entries(userTopicPoints).forEach(([topic, userPoints]) => {
const maxAchievablePoints = maxTopicPoints[topic];
if (userPoints === 0) {
return;
}
if (userPoints >= maxAchievablePoints) {
userTopicPoints[topic] = numMasteryLevels + 1;
return;
}
const unBoundedMasteryLevel =
(userPoints / maxAchievablePoints) * numMasteryLevels;
if (unBoundedMasteryLevel === numMasteryLevels) {
userTopicPoints[topic] = numMasteryLevels;
} else if (unBoundedMasteryLevel % 1 === 0) {
// Push them over to the next category if they are exactly on the edge.
userTopicPoints[topic] = unBoundedMasteryLevel + 1;
} else {
userTopicPoints[topic] = Math.ceil(unBoundedMasteryLevel);
}
});
const masteryMapping = {};
Object.entries(userTopicPoints).forEach(([topic, userPoints]) => {
masteryMapping[topic] = {"student_mastery": userPoints, "class_mastery": 0};
});
return masteryMapping;
}

router.get('/', async (req, res) => {
const { id } = req.params;
try {
const maxScores = await getMaxScores();
const studentScores = await getStudentScores(id);
const userTopicPoints = getTopicsFromUser(studentScores);
const maxTopicPoints = getTopicsFromUser(maxScores);
const masteryNum = await getMasteryMapping(userTopicPoints, maxTopicPoints);
return res.status(200).json(masteryNum);
} catch (err) {
switch (err.name) {
case 'StudentNotEnrolledError':
case 'KeyNotFoundError':
console.error('Error fetching student with id %s', id, err);
return res.status(404).json({ message: "Error fetching student."});
default:
console.error('Internal service error fetching student with id %s', id, err);
return res.status(500).json({ message: "Internal server error." });
}
}
});

export default router;
9 changes: 9 additions & 0 deletions api/v2/Routes/students/progressquerystring/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/**
* @deprecated This file is deprecated. It contains logic to produce a query string for CM, which is no longer used.
*/
import { Router } from 'express';
import {
getMaxScores,
Expand All @@ -7,6 +10,9 @@ import ProgressReportData from '../../../../assets/progressReport/CS10.json' ass

const router = Router({ mergeParams: true });

/**
* @deprecated Use the function with the same name in ../masterymapping/index.js instead.
*/
function getTopicsFromUser(gradeData) {
const topicsTable = {};
Object.entries(gradeData).forEach(([assignment, topics]) => {
Expand All @@ -21,6 +27,9 @@ function getTopicsFromUser(gradeData) {
return topicsTable;
}

/**
* @deprecated The query string is deprecated. GV now relays info to Concept Map using a POST request.
*/
async function getMasteryString(userTopicPoints, maxTopicPoints) {
const numMasteryLevels = ProgressReportData['student levels'].length - 2;
Object.entries(userTopicPoints).forEach(([topic, userPoints]) => {
Expand Down
94 changes: 89 additions & 5 deletions progressReport/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import json
import os
import parser
import jsonschema
from deprecated import deprecated

"""
Dream Team GUI
Expand Down Expand Up @@ -65,8 +67,44 @@

app = Flask(__name__)

with open("meta/defaults.json", "r") as defaults_file:
default = json.load(defaults_file)

DEFAULT_SCHOOL = default["school"]
DEFAULT_CLASS = default["class"]

"""
Validates the fields of the mastery learning post request
"""
def validate_mastery_learning_post_request(request_as_json):
schema = {
"type": "object",
# Matches mastery fields (fields other than "school" and "class"
"properties": {
# These fields are optional.
"school": {"type": "string"},
"class": {"type": "string"},
},
"additionalProperties": {
"type": "object",
"properties": {
"student_mastery": {"type": "integer"},
"class_mastery": {"type": "integer"}
},
"additionalProperties": False
},
}

# Intentionally not handling the error an improper format may produce for now.
jsonschema.validate(instance=request_as_json, schema=schema)

"""
This method is deprecated.
"""
@deprecated(reason="Query parameters are no longer used.")
@app.route('/', methods=["GET"])
def index():
print("In index")
def assign_node_levels(node, student_levels_count, class_levels_count):
nonlocal student_mastery, class_mastery
if not node["children"]:
Expand Down Expand Up @@ -107,7 +145,7 @@ def assign_node_levels(node, student_levels_count, class_levels_count):
with open("data/{}_{}.json".format(secure_filename(school_name), secure_filename(course_name))) as data_file:
course_data = json.load(data_file)
except FileNotFoundError:
return "Class not found", 400
return "Class not found", 404
start_date = course_data["start date"]
course_term = course_data["term"]
class_levels = course_data["class levels"]
Expand All @@ -126,18 +164,64 @@ def assign_node_levels(node, student_levels_count, class_levels_count):
course_data=course_nodes)


@app.route('/', methods=["POST"])
def generate_cm_from_post_parameters():
print("In generate_cm_from_post_parameters")
request_as_json = request.get_json()
print("Request", request)
validate_mastery_learning_post_request(request_as_json)
school_name = request_as_json.get("school", DEFAULT_SCHOOL)
course_name = request_as_json.get("class", DEFAULT_CLASS)
def assign_node_levels(node):
if not node["children"]:
node["student_level"] = request_as_json.get(node["name"], {}).get("student_mastery", 0)
node["class_level"] = request_as_json.get(node["name"], {}).get("class_mastery", 0)
else:
children_student_levels = []
children_class_levels = []
for child in node["children"]:
student_level, class_level = assign_node_levels(child)
children_student_levels.append(student_level)
children_class_levels.append(class_level)
node["student_level"] = sum(children_student_levels) // len(children_student_levels)
node["class_level"] = sum(children_class_levels) // len(children_class_levels)
return node["student_level"], node["class_level"]

parser.generate_map(school_name=secure_filename(school_name), course_name=secure_filename(course_name), render=True)
try:
with open("data/{}_{}.json".format(secure_filename(school_name), secure_filename(course_name))) as data_file:
course_data = json.load(data_file)
except FileNotFoundError:
return "Class not found", 404
start_date = course_data["start date"]
course_term = course_data["term"]
class_levels = course_data["class levels"]
student_levels = course_data["student levels"]
course_node_count = course_data["count"]
course_nodes = course_data["nodes"]
assign_node_levels(course_nodes)
return render_template("web_ui.html",
start_date=start_date,
course_name=course_name,
course_term=course_term,
class_levels=class_levels,
student_levels=student_levels,
use_url_class_mastery=False,
course_node_count=course_node_count,
course_data=course_nodes)


@app.route('/parse', methods=["POST"])
def parse():
school_name = request.args.get("school_name", "Berkeley")
course_name = request.form.get("course_name", "CS10")
school_name = request.args.get("school_name", DEFAULT_SCHOOL)
course_name = request.form.get("course_name", DEFAULT_CLASS)
parser.generate_map(school_name=secure_filename(school_name), course_name=secure_filename(course_name), render=False)
try:
with open("data/{}_{}.json".format(secure_filename(school_name), secure_filename(course_name))) as data_file:
course_data = json.load(data_file)
except FileNotFoundError:
return "Class not found", 400
return "Class not found", 404
return course_data


if __name__ == '__main__':
app.run()
1 change: 1 addition & 0 deletions progressReport/meta/defaults.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"school": "Berkeley", "class": "CS10"}
2 changes: 1 addition & 1 deletion progressReport/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,4 @@ def generate_map(school_name, course_name, render=False):
name, orientation, start_date, term, class_levels, student_levels, styles, root = read_meta(f)
to_json(school_name, course_name, term, start_date, class_levels, student_levels, root, render)
except FileNotFoundError:
return
return
2 changes: 2 additions & 0 deletions progressReport/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Flask
gunicorn
Werkzeug
jsonschema
deprecated
32 changes: 20 additions & 12 deletions website/src/views/conceptMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import './css/conceptMap.css';
import jwtDecode from 'jwt-decode';
import { StudentSelectionContext } from "../components/StudentSelectionWrapper";
import apiv2 from "../utils/apiv2";
import axios from "axios";

/**
* The ConceptMap component renders a concept map based on student progress data from the progressQueryString API.
Expand All @@ -23,12 +24,12 @@ import apiv2 from "../utils/apiv2";
*/
export default function ConceptMap() {
const [loading, setLoading] = useState(false);
const [studentMastery, setStudentMastery] = useState('000000');
const [conceptMapHTML, setConceptMapHTML] = useState('');

/** The iframeRef is initially set to null. Once the HTML webpage is loaded
* for the concept map, the iframeRef is dynamically set to the fetched
* progress report query string iframe for the selected student.
*/
*/
const iframeRef = useRef(null);

const { selectedStudent } = useContext(StudentSelectionContext);
Expand All @@ -44,7 +45,7 @@ export default function ConceptMap() {
}, []);

/**
* Fetch the logged-in student's mastery data on component mount (student view).
* Fetch the logged-in student's CM html on component mount (student view).
* This effect fetches data based on the JWT token stored
* in localStorage and updates the component's state.
*/
Expand All @@ -53,9 +54,12 @@ export default function ConceptMap() {
setLoading(true);
if (mounted && localStorage.getItem('token')) {
let email = jwtDecode(localStorage.getItem('token')).email;
// Fetch the student progressQueryString
apiv2.get(`/students/${email}/progressquerystring`).then((res) => {
setStudentMastery(res.data);
// Fetch the student masterymapping
apiv2.get(`/students/${email}/masterymapping`).then((res) => {
const conceptMapUrl = `${window.location.origin}/progress`;
axios.post(conceptMapUrl, res.data).then((res) => {
setConceptMapHTML(res.data);
});
setLoading(false);
});
} else {
Expand All @@ -65,17 +69,20 @@ export default function ConceptMap() {
}, []);

/**
* Fetch the selected student's mastery data whenever the selected student
* Fetch the selected student's CM html whenever the selected student
* changes for the instructor view.
* This effect depends on the `selectedStudent` from the context.
*/
useEffect(() => {
let mounted = true;
if (mounted) {
setLoading(true);
// Fetch the student progressQueryString
apiv2.get(`/students/${selectedStudent}/progressquerystring`).then((res) => {
setStudentMastery(res.data);
// Fetch the student masterymapping
apiv2.get(`/students/${selectedStudent}/masterymapping`).then((res) => {
const conceptMapUrl = `${window.location.origin}/progress`;
axios.post(conceptMapUrl, res.data).then((res) => {
setConceptMapHTML(res.data);
});
setLoading(false);
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we set the concept map html twice?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the selected student in the dropdown changes, the html for that student’s CM is retrieved and conceptMapHTML is updated accordingly. In the prior version of this file, this useEffect function used to update the query string when the selected student changed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh so its twice for backwards compatibility?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite. This code updates conceptMapHTML every time the selected student in the dropdown (available to users with admin privileges) changes to ensure that the corresponding CM is displayed. It isn’t to ensure backwards compatibility; I meant that this useEffect function currently serves a purpose analogous to its purpose in the prior version of this file.

}
Expand All @@ -99,13 +106,14 @@ export default function ConceptMap() {
ref={iframeRef}
className="concept_map_iframe"
id="ConceptMap"
name="ConceptMap"
title="Concept Map"
src={`${window.location.origin}/progress?show_legend=false&student_mastery=${studentMastery}`}
srcdoc={conceptMapHTML}
onLoad={handleLoad}
scrolling='no'
allowFullScreen
/>
</div>
</>
);
}
}
Loading