diff --git a/api/v2/Routes/students/index.js b/api/v2/Routes/students/index.js index 6f0a6a5..a9ed3e5 100644 --- a/api/v2/Routes/students/index.js +++ b/api/v2/Routes/students/index.js @@ -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'; @@ -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 { diff --git a/api/v2/Routes/students/masterymapping/index.js b/api/v2/Routes/students/masterymapping/index.js new file mode 100644 index 0000000..2773729 --- /dev/null +++ b/api/v2/Routes/students/masterymapping/index.js @@ -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; diff --git a/api/v2/Routes/students/progressquerystring/index.js b/api/v2/Routes/students/progressquerystring/index.js index 657fd64..7cac0f3 100644 --- a/api/v2/Routes/students/progressquerystring/index.js +++ b/api/v2/Routes/students/progressquerystring/index.js @@ -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, @@ -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]) => { @@ -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]) => { diff --git a/progressReport/app.py b/progressReport/app.py index d5c857b..c88f867 100644 --- a/progressReport/app.py +++ b/progressReport/app.py @@ -3,6 +3,8 @@ import json import os import parser +import jsonschema +from deprecated import deprecated """ Dream Team GUI @@ -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"]: @@ -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"] @@ -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() diff --git a/progressReport/meta/defaults.json b/progressReport/meta/defaults.json new file mode 100644 index 0000000..0372504 --- /dev/null +++ b/progressReport/meta/defaults.json @@ -0,0 +1 @@ +{"school": "Berkeley", "class": "CS10"} diff --git a/progressReport/parser.py b/progressReport/parser.py index a1b33b0..07a5b8b 100644 --- a/progressReport/parser.py +++ b/progressReport/parser.py @@ -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 \ No newline at end of file diff --git a/progressReport/requirements.txt b/progressReport/requirements.txt index 9e6cc65..f17fd88 100644 --- a/progressReport/requirements.txt +++ b/progressReport/requirements.txt @@ -1,3 +1,5 @@ Flask gunicorn Werkzeug +jsonschema +deprecated diff --git a/website/src/views/conceptMap.js b/website/src/views/conceptMap.js index 564ba4c..6a91f44 100644 --- a/website/src/views/conceptMap.js +++ b/website/src/views/conceptMap.js @@ -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. @@ -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); @@ -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. */ @@ -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 { @@ -65,7 +69,7 @@ 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. */ @@ -73,9 +77,12 @@ export default function ConceptMap() { 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); }); } @@ -99,8 +106,9 @@ 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 @@ -108,4 +116,4 @@ export default function ConceptMap() { ); -} +} \ No newline at end of file