For Developers

Using a Rubric to Grade an Assignment

Just want the code?

This example was built and tested on an integration using Canvas as the underlying source. Other LMS providers may differ slightly in what is supported.

Goal

Get an Assignment's Rubric, and use that to grade a Student's Submission.

Why

Teachers often want to use Rubrics for grading, and this serves as an example of how Edlink can facilitate that.

How

Before this script will work, you'll need a few things:

  1. Your valid Graph API token
  2. A valid set of class + assignment + submission UUIDs (make sure the Assignment you use was created with a rubric attached)

This script will use that information to:

  1. Call the Get Assignment endpoint to get the rubric_id.
  2. Call the Get Rubric endpoint to get the rubric's performance_level options.
  3. Get the user's choices for the student's grade.
  4. Call the Update Submission endpoint to grade the Submission.

Save the following script to grade-a-rubric.js

// IMPORTANT TODO - you must replace these ids with valid ids for your integration
const CLASS_ID = '00000000-0000-0000-0000-000000000000';
const ASSIGNMENT_ID = '00000000-0000-0000-0000-000000000000';
const SUBMISSION_ID = '00000000-0000-0000-0000-000000000000';

const EDLINK_BASE_URL = 'https://ed.link/api/v2/graph';

const access_token = process.argv[2];
if (!access_token) {
    console.error('access token must be provided as command line argument');
    process.exit(1);
}

// helper function for Edlink API calls
const fetchWithToken = async (url) => {
    if (url.startsWith('/')) {
        url = `${EDLINK_BASE_URL}${url}`;
    }

    const response = await fetch(url, {
        headers: {
            Authorization: `Bearer ${access_token}`
        }
    });

    if (!response.ok) {
        console.error(url, response);
        throw new Error(`HTTP error on ${url} Status: ${response.status}`);
    }
    return await response.json();
};

// helper class for fancy display
class InteractiveMenu {
    constructor(title, subtitle, options) {
        this.title = title;
        this.subtitle = subtitle;
        this.options = options;
        this.selectedIndex = 0;
        this.isActive = false;
    }

    // ANSI escape codes for terminal control
    static ANSI = {
        CLEAR_SCREEN: '\x1B[2J\x1B[0f',
        HIDE_CURSOR: '\x1B[?25l',
        SHOW_CURSOR: '\x1B[?25h',
        RESET: '\x1B[0m',
        DIM: '\x1B[2m',
        REVERSE: '\x1B[7m',
        GREEN: '\x1B[32m',
        YELLOW: '\x1B[33m',
        CYAN: '\x1B[36m'
    };

    render() {
        const { CLEAR_SCREEN, HIDE_CURSOR, DIM, REVERSE, CYAN, GREEN, YELLOW, RESET } = InteractiveMenu.ANSI;

        // Clear screen and hide cursor
        process.stdout.write(CLEAR_SCREEN + HIDE_CURSOR);

        // Display title
        console.log(`${CYAN}╔${'═'.repeat(this.title.length + 2)}╗${RESET}`);
        console.log(`${CYAN}║ ${this.title} ║${RESET}`);
        console.log(`${CYAN}╚${'═'.repeat(this.title.length + 2)}╝${RESET}\n`);

        // Display subtitle
        console.log(`${YELLOW}╔${'═'.repeat(this.subtitle.length + 2)}╗${RESET}`);
        console.log(`${YELLOW}║ ${this.subtitle} ║${RESET}`);
        console.log(`${YELLOW}╚${'═'.repeat(this.subtitle.length + 2)}╝${RESET}\n`);

        // Display options
        this.options.forEach((option, index) => {
            const isSelected = index === this.selectedIndex;
            const prefix = isSelected ? '▶ ' : '  ';
            const style = isSelected ? `${REVERSE}${GREEN}` : `${DIM}`;
            const icon = option.icon || '●';

            console.log(`${style}${prefix}${icon} ${option.text}${RESET}`);
        });

        // Display instructions
        console.log(`\n${DIM}Use ↑/↓ arrow keys to navigate, Enter to select, 'q' to quit${RESET}`);
    }

    moveUp() {
        this.selectedIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : this.options.length - 1;
        this.render();
    }

    moveDown() {
        this.selectedIndex = this.selectedIndex < this.options.length - 1 ? this.selectedIndex + 1 : 0;
        this.render();
    }

    select() {
        const selectedOption = this.options[this.selectedIndex];
        this.cleanup();
        if (selectedOption.result) {
            return selectedOption.result;
        }
        return selectedOption;
    }

    cleanup() {
        const { SHOW_CURSOR, CLEAR_SCREEN } = InteractiveMenu.ANSI;
        process.stdout.write(CLEAR_SCREEN + SHOW_CURSOR);
        process.stdin.setRawMode(false);
        this.isActive = false;
    }

    async show() {
        return new Promise((resolve, reject) => {
            this.isActive = true;

            // Set up raw mode to capture individual keystrokes
            process.stdin.setRawMode(true);
            process.stdin.resume();
            process.stdin.setEncoding('utf8');

            // Initial render
            this.render();

            // Handle keyboard input
            const keyHandler = (key) => {
                if (!this.isActive) return;

                switch (key) {
                    case '\u001B[A': // Up arrow
                        this.moveUp();
                        break;
                    case '\u001B[B': // Down arrow
                        this.moveDown();
                        break;
                    case '\r': // Enter
                        const selected = this.select();
                        process.stdin.removeListener('data', keyHandler);
                        this.cleanup();
                        resolve(selected);
                        break;
                    case 'q':
                    case 'Q':
                    case '\u0003': // Ctrl+C
                        process.stdin.removeListener('data', keyHandler);
                        this.cleanup();
                        resolve(null);
                        break;
                    default:
                        break; // Ignore other keys
                }
            };

            process.stdin.on('data', keyHandler);

            // Handle process termination
            process.on('SIGINT', () => {
                process.stdin.removeListener('data', keyHandler);
                this.cleanup();
                process.exit(0);
            });
        });
    }
}

/// Usage Example ///

// 1. GET the Assignment from Edlink
const assignment_response = await fetchWithToken(`/classes/${CLASS_ID}/assignments/${ASSIGNMENT_ID}`);
const assignment = assignment_response['$data'];
const rubric_id = assignment.rubric_id;
// 2. GET the Rubric from Edlink
const rubric_response = await fetchWithToken(`/classes/${CLASS_ID}/rubrics/${rubric_id}`);
const rubric = rubric_response['$data'];
// 3. Ask the user what grade to give the submission
let rubric_grade = {};
for (const criteria of rubric.grading_criteria) {
    const menu = new InteractiveMenu(
        `${rubric.title} - select student performance`,
        `${criteria.name} - ${criteria.description}`,
        criteria.performance_levels.map((l) => ({
            text: [l.name, l.description, `${l.points} points`].filter((a) => a).join(' - '),
            result: l.name
        }))
    );
    const choice = await menu.show();
    if (!choice) {
        console.error('no choice selected');
        process.exit(1);
    }
    rubric_grade[criteria.name] = choice;
}
// 4. PATCH the Submission in Edlink (using the grading choices the user made)
console.log('Your grading selections are:', rubric_grade);
const submission_response = await fetch(`${EDLINK_BASE_URL}/classes/${CLASS_ID}/assignments/${ASSIGNMENT_ID}/submissions/${SUBMISSION_ID}`, {
    method: 'PATCH',
    headers: {
        Authorization: `Bearer ${access_token}`,
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        rubric_grade
    })
});
// and... we're done
if (submission_response.ok) {
    console.log(`Submission ${SUBMISSION_ID} was graded. The Edlink response:`, await submission_response.json());
} else {
    console.error(`Something went wrong`, submission_response, await submission_response.text());
}

process.exit(0);

Now, running node grade-a-rubric.js MY_INTEGRATION_ACCESS_TOKEN will run the script and cause the specified Submission to be graded utilizing the rubric which attaches to the Assignment.