Added score evaluation.
This commit is contained in:
parent
2e1c61ad3c
commit
cdd1fe9b4f
|
@ -6,6 +6,7 @@
|
|||
"dependencies": {
|
||||
"asdf": "~>0.7.17",
|
||||
"ddbc": "~>0.6.2",
|
||||
"expression": "~>1.0.2",
|
||||
"handy-httpd": "~>8.4.3"
|
||||
},
|
||||
"description": "A minimal D application.",
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"ddbc": "0.6.2",
|
||||
"derelict-pq": "2.2.0",
|
||||
"derelict-util": "2.0.6",
|
||||
"expression": "1.0.2",
|
||||
"handy-httpd": "8.4.5",
|
||||
"httparsed": "1.2.1",
|
||||
"mir-algorithm": "3.22.3",
|
||||
|
|
|
@ -6,6 +6,8 @@ CREATE TABLE classroom_compliance_class (
|
|||
user_id BIGINT NOT NULL
|
||||
REFERENCES auth_user(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
score_expression VARCHAR(255) NOT NULL DEFAULT '0.3 * phone + 0.7 * (behavior_good * 1 + behavior_mediocre * 0.5)',
|
||||
score_period VARCHAR(64) NOT NULL DEFAULT 'week',
|
||||
CONSTRAINT unique_class_numbers_per_school_year
|
||||
UNIQUE(number, school_year, user_id)
|
||||
);
|
||||
|
|
|
@ -20,6 +20,7 @@ void registerApiEndpoints(PathHandler handler) {
|
|||
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
|
||||
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);
|
||||
handler.addMapping(Method.PUT, CLASS_PATH ~ "/reset-desk-numbers", &resetStudentDesks);
|
||||
handler.addMapping(Method.PUT, CLASS_PATH ~ "/score-parameters", &updateScoreParameters);
|
||||
|
||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent);
|
||||
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);
|
||||
|
|
|
@ -150,3 +150,51 @@ void resetStudentDesks(ref HttpRequestContext ctx) {
|
|||
const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?";
|
||||
update(conn, query, cls.id);
|
||||
}
|
||||
|
||||
void updateScoreParameters(ref HttpRequestContext ctx) {
|
||||
import api_modules.classroom_compliance.score_eval : validateExpression;
|
||||
import std.algorithm : canFind;
|
||||
Connection conn = getDb();
|
||||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
struct Payload {
|
||||
string scoreExpression;
|
||||
string scorePeriod;
|
||||
}
|
||||
Payload payload = readJsonPayload!(Payload)(ctx);
|
||||
bool scoreExpressionChanged = cls.scoreExpression != payload.scoreExpression;
|
||||
bool scorePeriodChanged = cls.scorePeriod != payload.scorePeriod;
|
||||
if (scoreExpressionChanged && !validateExpression(payload.scoreExpression)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Invalid score expression.");
|
||||
return;
|
||||
}
|
||||
const VALID_SCORE_PERIODS = ["week"];
|
||||
if (scorePeriodChanged && !canFind(VALID_SCORE_PERIODS, payload.scorePeriod)) {
|
||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||
ctx.response.writeBodyString("Invalid score period.");
|
||||
return;
|
||||
}
|
||||
conn.setAutoCommit(false);
|
||||
if (scoreExpressionChanged) {
|
||||
update(
|
||||
conn,
|
||||
"UPDATE classroom_compliance_class SET score_expression = ? WHERE id = ?",
|
||||
payload.scoreExpression,
|
||||
cls.id
|
||||
);
|
||||
}
|
||||
if (scorePeriodChanged) {
|
||||
update(
|
||||
conn,
|
||||
"UPDATE classroom_compliance_class SET score_period = ? WHERE id = ?",
|
||||
payload.scorePeriod,
|
||||
cls.id
|
||||
);
|
||||
}
|
||||
conn.commit();
|
||||
// Reload the class and then write it to the output.
|
||||
auto updatedClass = getClassOrThrow(ctx, conn, user);
|
||||
writeJsonBody(ctx, updatedClass);
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import slf4d;
|
|||
import api_modules.auth;
|
||||
import api_modules.classroom_compliance.model;
|
||||
import api_modules.classroom_compliance.util;
|
||||
import api_modules.classroom_compliance.score;
|
||||
import db;
|
||||
import data_utils;
|
||||
|
||||
|
@ -202,7 +203,7 @@ void getEntries(ref HttpRequestContext ctx) {
|
|||
}
|
||||
|
||||
// Find scores for each student for this timeframe.
|
||||
Optional!double[ulong] scores = getScores(conn, cls.id, dateRange);
|
||||
Optional!double[ulong] scores = getScores(conn, cls.id, dateRange.to);
|
||||
foreach (studentId, score; scores) {
|
||||
bool studentFound = false;
|
||||
foreach (ref studentObj; studentObjects) {
|
||||
|
@ -403,111 +404,3 @@ private void updateEntry(
|
|||
|
||||
infoF!"Updated entry %d"(entryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an associative array that maps student ids to their (optional) scores.
|
||||
* Scores are calculated based on aggregate statistics from their entries.
|
||||
* Params:
|
||||
* conn = The database connection.
|
||||
* classId = The id of the class to filter by.
|
||||
* dateRange = The date range to calculate scores for.
|
||||
* Returns: A map of scores.
|
||||
*/
|
||||
Optional!double[ulong] getScores(
|
||||
Connection conn,
|
||||
ulong classId,
|
||||
in DateRange dateRange
|
||||
) {
|
||||
Optional!double[ulong] scores;
|
||||
|
||||
const query = "
|
||||
SELECT
|
||||
student_id,
|
||||
COUNT(id) AS entry_count,
|
||||
SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count,
|
||||
SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count,
|
||||
SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good,
|
||||
SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
|
||||
SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor
|
||||
FROM classroom_compliance_entry
|
||||
WHERE
|
||||
date >= ?
|
||||
AND date <= ?
|
||||
AND class_id = ?
|
||||
GROUP BY student_id
|
||||
";
|
||||
PreparedStatement ps = conn.prepareStatement(query);
|
||||
scope(exit) ps.close();
|
||||
ps.setDate(1, dateRange.from);
|
||||
ps.setDate(2, dateRange.to);
|
||||
ps.setUlong(3, classId);
|
||||
foreach (DataSetReader r; ps.executeQuery()) {
|
||||
ulong studentId = r.getUlong(1);
|
||||
uint entryCount = r.getUint(2);
|
||||
uint absenceCount = r.getUint(3);
|
||||
uint phoneNonComplianceCount = r.getUint(4);
|
||||
uint behaviorGoodCount = r.getUint(5);
|
||||
uint behaviorMediocreCount = r.getUint(6);
|
||||
uint behaviorPoorCount = r.getUint(7);
|
||||
scores[studentId] = calculateScore(
|
||||
entryCount,
|
||||
absenceCount,
|
||||
phoneNonComplianceCount,
|
||||
behaviorGoodCount,
|
||||
behaviorMediocreCount,
|
||||
behaviorPoorCount
|
||||
);
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the score for a particular student, using the following formula:
|
||||
* 1. Ignore all absent days.
|
||||
* 2. Calculate phone score as compliantDays / total.
|
||||
* 3. Calculate behavior score as:
|
||||
* sum(goodBehaviorDays + 0.5 * mediocreBehaviorDays) / total
|
||||
* 4. Final score is 0.3 * phoneScore + 0.7 * behaviorScore.
|
||||
* Params:
|
||||
* entryCount = The number of entries for a student.
|
||||
* absenceCount = The number of absences the student has.
|
||||
* phoneNonComplianceCount = The number of times the student was not phone-compliant.
|
||||
* behaviorGoodCount = The number of days of good behavior.
|
||||
* behaviorMediocreCount = The number of days of mediocre behavior.
|
||||
* behaviorPoorCount = The number of days of poor behavior.
|
||||
* Returns: The score, or an empty optional if there isn't enough data.
|
||||
*/
|
||||
private Optional!double calculateScore(
|
||||
uint entryCount,
|
||||
uint absenceCount,
|
||||
uint phoneNonComplianceCount,
|
||||
uint behaviorGoodCount,
|
||||
uint behaviorMediocreCount,
|
||||
uint behaviorPoorCount
|
||||
) {
|
||||
if (
|
||||
entryCount == 0
|
||||
|| entryCount <= absenceCount
|
||||
) return Optional!double.empty;
|
||||
|
||||
const uint presentCount = entryCount - absenceCount;
|
||||
|
||||
// Phone subscore:
|
||||
uint phoneCompliantCount;
|
||||
if (presentCount < phoneNonComplianceCount) {
|
||||
phoneCompliantCount = 0;
|
||||
} else {
|
||||
phoneCompliantCount = presentCount - phoneNonComplianceCount;
|
||||
}
|
||||
double phoneScore = phoneCompliantCount / cast(double) presentCount;
|
||||
|
||||
// Behavior subscore:
|
||||
double behaviorScore = (
|
||||
behaviorGoodCount * 1.0
|
||||
+ behaviorMediocreCount * 0.5
|
||||
+ behaviorPoorCount * 0
|
||||
) / cast(double) presentCount;
|
||||
|
||||
double score = 0.3 * phoneScore + 0.7 * behaviorScore;
|
||||
return Optional!double.of(score);
|
||||
}
|
||||
|
|
|
@ -10,13 +10,17 @@ struct ClassroomComplianceClass {
|
|||
const ushort number;
|
||||
const string schoolYear;
|
||||
const ulong userId;
|
||||
const string scoreExpression;
|
||||
const string scorePeriod;
|
||||
|
||||
static ClassroomComplianceClass parse(DataSetReader r) {
|
||||
return ClassroomComplianceClass(
|
||||
r.getUlong(1),
|
||||
r.getUshort(2),
|
||||
r.getString(3),
|
||||
r.getUlong(4)
|
||||
r.getUlong(4),
|
||||
r.getString(5),
|
||||
r.getString(6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,167 @@
|
|||
module api_modules.classroom_compliance.score;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import ddbc;
|
||||
import slf4d;
|
||||
import std.datetime;
|
||||
|
||||
import api_modules.classroom_compliance.util;
|
||||
import api_modules.classroom_compliance.score_eval;
|
||||
import db;
|
||||
|
||||
enum ScorePeriod : string {
|
||||
WEEK = "week"
|
||||
}
|
||||
|
||||
private struct ScoringParameters {
|
||||
Expr expr;
|
||||
const DateRange dateRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an associative array that maps student ids to their (optional) scores.
|
||||
* Scores are calculated based on aggregate statistics from their entries. A
|
||||
* student's score will be empty if they do not have any data available, or are
|
||||
* absent for all dates within the grading period.
|
||||
* Params:
|
||||
* conn = The database connection.
|
||||
* classId = The id of the class to filter by.
|
||||
* date = The date to calculate scores for.
|
||||
* Returns: A map of scores.
|
||||
*/
|
||||
Optional!double[ulong] getScores(
|
||||
Connection conn,
|
||||
ulong classId,
|
||||
Date date
|
||||
) {
|
||||
Optional!double[ulong] scores;
|
||||
auto optScoringParams = getScoringParameters(conn, classId, date);
|
||||
if (optScoringParams.isNull) {
|
||||
warnF!"Unable to obtain scoring parameters for class %d on %s."(classId, date.toISOExtString());
|
||||
return scores;
|
||||
}
|
||||
const ScoringParameters params = optScoringParams.value;
|
||||
debugF!"Calculating scores for class %d using expression (%s) from %s to %s."(
|
||||
classId,
|
||||
params.expr.toString(),
|
||||
params.dateRange.from,
|
||||
params.dateRange.to
|
||||
);
|
||||
|
||||
const query = "
|
||||
SELECT
|
||||
student_id,
|
||||
COUNT(id) AS entry_count,
|
||||
SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count,
|
||||
SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count,
|
||||
SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good,
|
||||
SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
|
||||
SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor
|
||||
FROM classroom_compliance_entry
|
||||
WHERE
|
||||
date >= ?
|
||||
AND date <= ?
|
||||
AND class_id = ?
|
||||
GROUP BY student_id
|
||||
";
|
||||
PreparedStatement ps = conn.prepareStatement(query);
|
||||
scope(exit) ps.close();
|
||||
ps.setDate(1, params.dateRange.from);
|
||||
ps.setDate(2, params.dateRange.to);
|
||||
ps.setUlong(3, classId);
|
||||
foreach (DataSetReader r; ps.executeQuery()) {
|
||||
ulong studentId = r.getUlong(1);
|
||||
uint entryCount = r.getUint(2);
|
||||
uint absenceCount = r.getUint(3);
|
||||
uint phoneNonComplianceCount = r.getUint(4);
|
||||
uint behaviorGoodCount = r.getUint(5);
|
||||
uint behaviorMediocreCount = r.getUint(6);
|
||||
uint behaviorPoorCount = r.getUint(7);
|
||||
scores[studentId] = calculateScore(
|
||||
params.expr,
|
||||
entryCount,
|
||||
absenceCount,
|
||||
phoneNonComplianceCount,
|
||||
behaviorGoodCount,
|
||||
behaviorMediocreCount,
|
||||
behaviorPoorCount
|
||||
);
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
||||
private Optional!ScoringParameters getScoringParameters(Connection conn, ulong classId, in Date date) {
|
||||
const q = "SELECT score_expression, score_period FROM classroom_compliance_class WHERE id = ?";
|
||||
PreparedStatement ps = conn.prepareStatement(q);
|
||||
scope(exit) ps.close();
|
||||
ps.setUlong(1, classId);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
scope(exit) rs.close();
|
||||
if (!rs.first()) return Optional!ScoringParameters.empty;
|
||||
string expr = rs.getString(1);
|
||||
string period = rs.getString(2);
|
||||
return Optional!ScoringParameters.of(ScoringParameters(
|
||||
parseExpression(expr),
|
||||
getDateRangeFromPeriodAndDate(date, period)
|
||||
));
|
||||
}
|
||||
|
||||
private DateRange getDateRangeFromPeriodAndDate(in Date date, in string period) {
|
||||
if (period == ScorePeriod.WEEK) {
|
||||
Date friday = date;
|
||||
if (date.dayOfWeek() == DayOfWeek.sun) {
|
||||
friday = date - days(2);
|
||||
} else {
|
||||
int diff = DayOfWeek.fri - date.dayOfWeek();
|
||||
friday = date + days(diff);
|
||||
}
|
||||
Date monday = friday - days(4);
|
||||
return DateRange(monday, friday);
|
||||
} else {
|
||||
throw new Exception("Unsupported period: " ~ period);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the score for a particular student, using the formula defined
|
||||
* by the class.
|
||||
* Returns: The score, or an empty optional if there isn't enough data.
|
||||
*/
|
||||
private Optional!double calculateScore(
|
||||
in Expr scoreExpression,
|
||||
uint entryCount,
|
||||
uint absenceCount,
|
||||
uint phoneNonComplianceCount,
|
||||
uint behaviorGoodCount,
|
||||
uint behaviorMediocreCount,
|
||||
uint behaviorPoorCount
|
||||
) {
|
||||
if (
|
||||
entryCount == 0
|
||||
|| entryCount <= absenceCount
|
||||
) return Optional!double.empty;
|
||||
|
||||
const uint presentCount = entryCount - absenceCount;
|
||||
|
||||
// Phone subscore:
|
||||
uint phoneCompliantCount;
|
||||
if (presentCount < phoneNonComplianceCount) {
|
||||
phoneCompliantCount = 0;
|
||||
} else {
|
||||
phoneCompliantCount = presentCount - phoneNonComplianceCount;
|
||||
}
|
||||
double phoneScore = phoneCompliantCount / cast(double) presentCount;
|
||||
|
||||
double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
|
||||
double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
|
||||
double behaviorPoorScore = behaviorPoorCount / cast(double) presentCount;
|
||||
double typicalBehaviorScore = (1.0 * behaviorGoodScore + 0.5 * behaviorMediocreScore);
|
||||
|
||||
return Optional!double.of(scoreExpression.eval([
|
||||
"phone": phoneScore,
|
||||
"behavior": typicalBehaviorScore,
|
||||
"behavior_good": behaviorGoodScore,
|
||||
"behavior_mediocre": behaviorMediocreScore,
|
||||
"behavior_poor": behaviorPoorScore
|
||||
]));
|
||||
}
|
|
@ -0,0 +1,346 @@
|
|||
module api_modules.classroom_compliance.score_eval;
|
||||
|
||||
import std.regex;
|
||||
import std.uni;
|
||||
import std.string;
|
||||
import std.range;
|
||||
import std.array;
|
||||
import std.conv;
|
||||
|
||||
Expr parseExpression(string expr) {
|
||||
Token[] tokens = lexer(expr);
|
||||
return parseExpr(tokens);
|
||||
}
|
||||
|
||||
double evaluateExpression(string expr, double[string] ctx = (double[string]).init) {
|
||||
Expr ast = parseExpression(expr);
|
||||
return ast.eval(ctx);
|
||||
}
|
||||
|
||||
unittest {
|
||||
assert(evaluateExpression("3 + 4") == 7.0);
|
||||
assert(evaluateExpression("3 + 4 + 1") == 8.0);
|
||||
assert(evaluateExpression("3 * 4 + 1") == 13.0);
|
||||
assert(evaluateExpression("3 * (4 + 1)") == 15.0);
|
||||
assert(evaluateExpression("(1 + 2) * (3 + 5)") == 24.0);
|
||||
Expr e1 = parseExpression("x + 1");
|
||||
assert(e1.eval(["x": 1.0]) == 2.0);
|
||||
assert(e1.eval(["x": 3.0]) == 4.0);
|
||||
Expr e2 = parseExpression("0.3 * phone + 0.7 * (behavior_good * 1 + behavior_mediocre * 0.5)");
|
||||
// Test a perfect score first.
|
||||
assert(e2.eval([
|
||||
"phone": 1.0,
|
||||
"behavior_good": 1.0,
|
||||
"behavior_mediocre": 0.0
|
||||
]) == 1.0);
|
||||
// Test 0.5 phone score.
|
||||
assert(e2.eval([
|
||||
"phone": 0.5,
|
||||
"behavior_good": 1.0,
|
||||
"behavior_mediocre": 0.0
|
||||
]) == 0.85);
|
||||
// Test 0.75 phone, 0.5 good behavior, 0.5 mediocre behavior.
|
||||
double d = e2.eval([
|
||||
"phone": 0.75,
|
||||
"behavior_good": 0.5,
|
||||
"behavior_mediocre": 0.5
|
||||
]);
|
||||
assert(d > 0.7499 && d < 0.7501);
|
||||
}
|
||||
|
||||
bool validateExpression(string expr) {
|
||||
try {
|
||||
auto _ = parseExpression(expr);
|
||||
return true;
|
||||
} catch (ExpressionParsingException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class ExpressionParsingException : Exception {
|
||||
this(string msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class UnexpectedTokenException : ExpressionParsingException {
|
||||
Token token;
|
||||
this(Token token) {
|
||||
super("Unexpected token found while parsing expression: " ~ token.toString());
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
class ExpressionEvaluationException : Exception {
|
||||
this(string msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
class UndefinedVariableException : ExpressionEvaluationException {
|
||||
string variableName;
|
||||
this(string variableName) {
|
||||
super("No value is defined for the variable \"" ~ variableName ~ "\".");
|
||||
this.variableName = variableName;
|
||||
}
|
||||
}
|
||||
|
||||
private enum TokenType {
|
||||
ADD,
|
||||
SUB,
|
||||
MUL,
|
||||
DIV,
|
||||
PAREN_OPEN,
|
||||
PAREN_CLOSE,
|
||||
NUMBER,
|
||||
VARIABLE
|
||||
}
|
||||
|
||||
private interface Token {
|
||||
TokenType getType() const;
|
||||
string toString() const;
|
||||
}
|
||||
|
||||
private class SimpleToken : Token {
|
||||
const TokenType type;
|
||||
this(TokenType type) {
|
||||
this.type = type;
|
||||
}
|
||||
override TokenType getType() const {
|
||||
return type;
|
||||
}
|
||||
override string toString() const {
|
||||
return type.to!string;
|
||||
}
|
||||
}
|
||||
|
||||
private class NumberToken : Token {
|
||||
const double value;
|
||||
this(double value) {
|
||||
this.value = value;
|
||||
}
|
||||
override TokenType getType() const {
|
||||
return TokenType.NUMBER;
|
||||
}
|
||||
override string toString() const {
|
||||
return value.to!string;
|
||||
}
|
||||
}
|
||||
|
||||
private class VariableToken : Token {
|
||||
const string name;
|
||||
this(string name) {
|
||||
this.name = name;
|
||||
}
|
||||
override TokenType getType() const {
|
||||
return TokenType.VARIABLE;
|
||||
}
|
||||
override string toString() const {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private Token[] lexer(string expr) {
|
||||
auto app = appender!(Token[]);
|
||||
size_t idx = 0;
|
||||
while (idx < expr.length) {
|
||||
const char c = expr[idx];
|
||||
if (isWhite(c)) {
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
if (isNumber(c)) {
|
||||
app ~= lexNumber(expr, idx);
|
||||
} else if (isAlpha(c)) {
|
||||
app ~= lexVariable(expr, idx);
|
||||
} else {
|
||||
switch (c) {
|
||||
case '+':
|
||||
app ~= new SimpleToken(TokenType.ADD);
|
||||
break;
|
||||
case '-':
|
||||
app ~= new SimpleToken(TokenType.SUB);
|
||||
break;
|
||||
case '*':
|
||||
app ~= new SimpleToken(TokenType.MUL);
|
||||
break;
|
||||
case '/':
|
||||
app ~= new SimpleToken(TokenType.DIV);
|
||||
break;
|
||||
case '(':
|
||||
app ~= new SimpleToken(TokenType.PAREN_OPEN);
|
||||
break;
|
||||
case ')':
|
||||
app ~= new SimpleToken(TokenType.PAREN_CLOSE);
|
||||
break;
|
||||
default:
|
||||
throw new Exception("Invalid token: " ~ expr);
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return app[];
|
||||
}
|
||||
|
||||
unittest {
|
||||
Token[] t1 = lexer("3 + 4");
|
||||
assert(t1.length == 3);
|
||||
assert(t1[0].getType() == TokenType.NUMBER);
|
||||
assert((cast(NumberToken) t1[0]).value == 3.0);
|
||||
assert(t1[1].getType() == TokenType.ADD);
|
||||
assert(t1[2].getType() == TokenType.NUMBER);
|
||||
assert((cast(NumberToken) t1[2]).value == 4.0);
|
||||
}
|
||||
|
||||
private NumberToken lexNumber(string s, ref size_t idx) {
|
||||
const r = regex("\\d+(\\.\\d+)?");
|
||||
auto captures = matchFirst(s[idx .. $], r);
|
||||
if (captures.empty) {
|
||||
throw new ExpressionParsingException("Failed to read number from string: " ~ s[idx .. $]);
|
||||
}
|
||||
idx += captures.front.length;
|
||||
return new NumberToken(captures.front.to!double);
|
||||
}
|
||||
|
||||
private VariableToken lexVariable(string s, ref size_t idx) {
|
||||
const r = regex("[a-zA-Z_]+");
|
||||
auto captures = matchFirst(s[idx .. $], r);
|
||||
if (captures.empty) {
|
||||
throw new ExpressionParsingException("Failed to read variable from string: " ~ s[idx .. $]);
|
||||
}
|
||||
idx += captures.front.length;
|
||||
return new VariableToken(captures.front);
|
||||
}
|
||||
|
||||
interface Expr {
|
||||
double eval(double[string] context) const;
|
||||
string toString() const;
|
||||
}
|
||||
|
||||
private class BinaryOpExpr : Expr {
|
||||
private TokenType op;
|
||||
private Expr left;
|
||||
private Expr right;
|
||||
|
||||
this(TokenType op, Expr left, Expr right) {
|
||||
this.op = op;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
}
|
||||
|
||||
double eval(double[string] context) const {
|
||||
const l = left.eval(context);
|
||||
const r = right.eval(context);
|
||||
switch (op) {
|
||||
case TokenType.ADD:
|
||||
return l + r;
|
||||
case TokenType.SUB:
|
||||
return l - r;
|
||||
case TokenType.MUL:
|
||||
return l * r;
|
||||
case TokenType.DIV:
|
||||
return l / r;
|
||||
default:
|
||||
throw new ExpressionEvaluationException("Invalid binary operation: " ~ op.to!string);
|
||||
}
|
||||
}
|
||||
|
||||
override string toString() const {
|
||||
return "(" ~ left.toString() ~ " " ~ this.op.to!string ~ " " ~ right.toString() ~ ")";
|
||||
}
|
||||
}
|
||||
|
||||
private class NumberLiteralExpr : Expr {
|
||||
private double value;
|
||||
|
||||
this(double value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
double eval(double[string] context) const {
|
||||
return value;
|
||||
}
|
||||
|
||||
override string toString() const {
|
||||
return value.to!string;
|
||||
}
|
||||
}
|
||||
|
||||
private class VariableExpr : Expr {
|
||||
private string variable;
|
||||
|
||||
this(string variable) {
|
||||
this.variable = variable;
|
||||
}
|
||||
|
||||
double eval(double[string] context) const {
|
||||
if (variable !in context) throw new UndefinedVariableException(variable);
|
||||
return context[variable];
|
||||
}
|
||||
|
||||
override string toString() const {
|
||||
return variable;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private Expr parseExpr(ref Token[] tokens) {
|
||||
Expr left = parseTerm(tokens);
|
||||
if (empty(tokens)) return left;
|
||||
Token t = front(tokens);
|
||||
if (t.getType() == TokenType.ADD) {
|
||||
popFront(tokens);
|
||||
Expr right = parseExpr(tokens);
|
||||
return new BinaryOpExpr(TokenType.ADD, left, right);
|
||||
} else if (t.getType() == TokenType.SUB) {
|
||||
popFront(tokens);
|
||||
Expr right = parseExpr(tokens);
|
||||
return new BinaryOpExpr(TokenType.SUB, left, right);
|
||||
} else {
|
||||
throw new UnexpectedTokenException(t);
|
||||
}
|
||||
}
|
||||
|
||||
private Expr parseTerm(ref Token[] tokens) {
|
||||
Expr left = parseFactor(tokens);
|
||||
if (empty(tokens)) return left;
|
||||
Token t = front(tokens);
|
||||
if (t.getType() == TokenType.MUL) {
|
||||
popFront(tokens);
|
||||
Expr right = parseFactor(tokens);
|
||||
return new BinaryOpExpr(TokenType.MUL, left, right);
|
||||
} else if (t.getType() == TokenType.DIV) {
|
||||
popFront(tokens);
|
||||
Expr right = parseFactor(tokens);
|
||||
return new BinaryOpExpr(TokenType.DIV, left, right);
|
||||
} else {
|
||||
return left;
|
||||
}
|
||||
}
|
||||
|
||||
private Expr parseFactor(ref Token[] tokens) {
|
||||
Token t = front(tokens);
|
||||
if (t.getType() == TokenType.VARIABLE) {
|
||||
VariableToken vt = cast(VariableToken) t;
|
||||
popFront(tokens);
|
||||
return new VariableExpr(vt.name);
|
||||
} else if (t.getType() == TokenType.NUMBER) {
|
||||
NumberToken nt = cast(NumberToken) t;
|
||||
popFront(tokens);
|
||||
return new NumberLiteralExpr(nt.value);
|
||||
} else if (t.getType() == TokenType.PAREN_OPEN) {
|
||||
int i = 1;
|
||||
int pCount = 1;
|
||||
while (i < tokens.length && pCount > 0) {
|
||||
if (tokens[i].getType() == TokenType.PAREN_OPEN) pCount++;
|
||||
if (tokens[i].getType() == TokenType.PAREN_CLOSE) pCount--;
|
||||
i++;
|
||||
}
|
||||
if (pCount != 0) throw new ExpressionParsingException("Unbalanced parentheses.");
|
||||
Token[] subExprTokens = tokens[1 .. i - 1];
|
||||
for (size_t idx = 0; idx < i; idx++) popFront(tokens);
|
||||
return parseExpr(subExprTokens);
|
||||
} else {
|
||||
throw new UnexpectedTokenException(t);
|
||||
}
|
||||
}
|
|
@ -14,6 +14,8 @@ export interface Class {
|
|||
id: number
|
||||
number: number
|
||||
schoolYear: string
|
||||
scoreExpression: string
|
||||
scorePeriod: string
|
||||
}
|
||||
|
||||
export interface ClassesResponseClass {
|
||||
|
@ -150,6 +152,17 @@ export class ClassroomComplianceAPIClient extends APIClient {
|
|||
return new APIResponse(this.handleAPIResponseWithNoBody(promise))
|
||||
}
|
||||
|
||||
updateScoreParameters(
|
||||
classId: number,
|
||||
scoreExpression: string,
|
||||
scorePeriod: string,
|
||||
): APIResponse<Class> {
|
||||
return super.put(`/classes/${classId}/score-parameters`, {
|
||||
scoreExpression: scoreExpression,
|
||||
scorePeriod: scorePeriod,
|
||||
})
|
||||
}
|
||||
|
||||
getStudents(classId: number): APIResponse<Student[]> {
|
||||
return super.get(`/classes/${classId}/students`)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
|
@ -15,12 +15,20 @@ const router = useRouter()
|
|||
const cls: Ref<Class | null> = ref(null)
|
||||
const notes: Ref<ClassNote[]> = ref([])
|
||||
const noteContent: Ref<string> = ref('')
|
||||
const scoreExpression: Ref<string> = ref('')
|
||||
const scorePeriod: Ref<string> = ref('')
|
||||
const canUpdateScoreParameters = computed(() => {
|
||||
return cls.value && (
|
||||
cls.value.scoreExpression !== scoreExpression.value ||
|
||||
cls.value.scorePeriod !== scorePeriod.value
|
||||
)
|
||||
})
|
||||
|
||||
const entriesTable = useTemplateRef('entries-table')
|
||||
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
||||
|
||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||
|
||||
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
||||
onMounted(() => {
|
||||
loadClass()
|
||||
})
|
||||
|
@ -31,6 +39,8 @@ function loadClass() {
|
|||
if (result) {
|
||||
cls.value = result
|
||||
refreshNotes()
|
||||
scoreExpression.value = cls.value.scoreExpression
|
||||
scorePeriod.value = cls.value.scorePeriod
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
|
@ -75,6 +85,28 @@ async function resetStudentDesks() {
|
|||
// Reload the table!
|
||||
await entriesTable.value?.loadEntries()
|
||||
}
|
||||
|
||||
async function submitScoreParametersUpdate() {
|
||||
if (!cls.value) return
|
||||
const result = await apiClient.updateScoreParameters(
|
||||
cls.value.id,
|
||||
scoreExpression.value,
|
||||
scorePeriod.value
|
||||
).handleErrorsWithAlert()
|
||||
if (result === null) return
|
||||
// The class was updated successfully, so update the page's values.
|
||||
cls.value = result
|
||||
scoreExpression.value = cls.value.scoreExpression
|
||||
scorePeriod.value = cls.value.scorePeriod
|
||||
// Reload the table with the updated scores.
|
||||
await entriesTable.value?.loadEntries()
|
||||
}
|
||||
|
||||
function resetScoreParameters() {
|
||||
if (!cls.value) return
|
||||
scoreExpression.value = cls.value.scoreExpression
|
||||
scorePeriod.value = cls.value.scorePeriod
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="cls">
|
||||
|
@ -101,6 +133,56 @@ async function resetStudentDesks() {
|
|||
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="margin-bottom: 0.25em;">Scoring</h3>
|
||||
<p style="margin-top: 0.25em; margin-bottom: 0.25em;">
|
||||
Change how scores are calculated for this class here.
|
||||
</p>
|
||||
<form @submit.prevent="submitScoreParametersUpdate">
|
||||
<div>
|
||||
<label for="score-expression-input">Expression</label>
|
||||
<textarea id="score-expression-input" v-model="scoreExpression" class="text-mono" minlength="1"
|
||||
maxlength="255" style="min-width: 500px; min-height: 50px;"></textarea>
|
||||
</div>
|
||||
<p class="form-input-hint">
|
||||
The expression to use to calculate each student's score. This should be a simple mathematical expression that
|
||||
can use the following variables:
|
||||
</p>
|
||||
<ul class="form-input-hint">
|
||||
<li><span class="score-expression-variable">phone</span> - The student's phone score, defined as the number of
|
||||
compliant days divided by total days present.</li>
|
||||
<li><span class="score-expression-variable">behavior_good</span> - The proportion of days that the student had
|
||||
good behavior.</li>
|
||||
<li><span class="score-expression-variable">behavior_mediocre</span> - The proportion of days that the student
|
||||
had mediocre behavior.</li>
|
||||
<li><span class="score-expression-variable">behavior_poor</span> - The proportion of days that the student had
|
||||
poor behavior.</li>
|
||||
<li><span class="score-expression-variable">behavior</span> - A general behavior score, where a student gets
|
||||
full points for good behavior, half-points for mediocre behavior, and no points for poor behavior.</li>
|
||||
</ul>
|
||||
<p class="form-input-hint">
|
||||
As an example, a common scoring expression might be 50% phone-compliance, and 50% behavior. The expression
|
||||
below would achieve that:
|
||||
</p>
|
||||
<p style="font-family: 'SourceCodePro', monospace; font-size: smaller;">
|
||||
0.5 * phone + 0.5 * behavior
|
||||
</p>
|
||||
<div>
|
||||
<label for="score-period-select">Period</label>
|
||||
<select v-model="scorePeriod">
|
||||
<option value="week">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="form-input-hint">
|
||||
The period over which to calculate scores.
|
||||
</p>
|
||||
<div class="button-bar">
|
||||
<button type="submit" :disabled="!canUpdateScoreParameters">Save</button>
|
||||
<button type="button" :disabled="!canUpdateScoreParameters" @click="resetScoreParameters">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation dialog used for attempts at deleting this class. -->
|
||||
<ConfirmDialog ref="deleteClassDialog">
|
||||
<p>
|
||||
|
@ -111,3 +193,10 @@ async function resetStudentDesks() {
|
|||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.score-expression-variable {
|
||||
font-family: 'SourceCodePro', monospace;
|
||||
font-style: normal;
|
||||
color: rgb(165, 210, 253);
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in New Issue