Compare commits
No commits in common. "b56eee21784a9d3e70e6bcd2589284daf9601f1b" and "ac01b5c94ccb62c989b397639c7769fe873b9555" have entirely different histories.
b56eee2178
...
ac01b5c94c
|
@ -6,8 +6,6 @@ CREATE TABLE classroom_compliance_class (
|
||||||
user_id BIGINT NOT NULL
|
user_id BIGINT NOT NULL
|
||||||
REFERENCES auth_user(id)
|
REFERENCES auth_user(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
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
|
CONSTRAINT unique_class_numbers_per_school_year
|
||||||
UNIQUE(number, school_year, user_id)
|
UNIQUE(number, school_year, user_id)
|
||||||
);
|
);
|
||||||
|
|
|
@ -20,7 +20,6 @@ void registerApiEndpoints(PathHandler handler) {
|
||||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
|
||||||
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);
|
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 ~ "/reset-desk-numbers", &resetStudentDesks);
|
||||||
handler.addMapping(Method.PUT, CLASS_PATH ~ "/score-parameters", &updateScoreParameters);
|
|
||||||
|
|
||||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent);
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent);
|
||||||
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);
|
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);
|
||||||
|
|
|
@ -150,51 +150,3 @@ void resetStudentDesks(ref HttpRequestContext ctx) {
|
||||||
const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?";
|
const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?";
|
||||||
update(conn, query, cls.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,7 +12,6 @@ import slf4d;
|
||||||
import api_modules.auth;
|
import api_modules.auth;
|
||||||
import api_modules.classroom_compliance.model;
|
import api_modules.classroom_compliance.model;
|
||||||
import api_modules.classroom_compliance.util;
|
import api_modules.classroom_compliance.util;
|
||||||
import api_modules.classroom_compliance.score;
|
|
||||||
import db;
|
import db;
|
||||||
import data_utils;
|
import data_utils;
|
||||||
|
|
||||||
|
@ -203,7 +202,7 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find scores for each student for this timeframe.
|
// Find scores for each student for this timeframe.
|
||||||
Optional!double[ulong] scores = getScores(conn, cls.id, dateRange.to);
|
Optional!double[ulong] scores = getScores(conn, cls.id, dateRange);
|
||||||
foreach (studentId, score; scores) {
|
foreach (studentId, score; scores) {
|
||||||
bool studentFound = false;
|
bool studentFound = false;
|
||||||
foreach (ref studentObj; studentObjects) {
|
foreach (ref studentObj; studentObjects) {
|
||||||
|
@ -404,3 +403,111 @@ private void updateEntry(
|
||||||
|
|
||||||
infoF!"Updated entry %d"(entryId);
|
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,17 +10,13 @@ struct ClassroomComplianceClass {
|
||||||
const ushort number;
|
const ushort number;
|
||||||
const string schoolYear;
|
const string schoolYear;
|
||||||
const ulong userId;
|
const ulong userId;
|
||||||
const string scoreExpression;
|
|
||||||
const string scorePeriod;
|
|
||||||
|
|
||||||
static ClassroomComplianceClass parse(DataSetReader r) {
|
static ClassroomComplianceClass parse(DataSetReader r) {
|
||||||
return ClassroomComplianceClass(
|
return ClassroomComplianceClass(
|
||||||
r.getUlong(1),
|
r.getUlong(1),
|
||||||
r.getUshort(2),
|
r.getUshort(2),
|
||||||
r.getString(3),
|
r.getString(3),
|
||||||
r.getUlong(4),
|
r.getUlong(4)
|
||||||
r.getString(5),
|
|
||||||
r.getString(6)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,167 +0,0 @@
|
||||||
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
|
|
||||||
]));
|
|
||||||
}
|
|
|
@ -1,346 +0,0 @@
|
||||||
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,8 +14,6 @@ export interface Class {
|
||||||
id: number
|
id: number
|
||||||
number: number
|
number: number
|
||||||
schoolYear: string
|
schoolYear: string
|
||||||
scoreExpression: string
|
|
||||||
scorePeriod: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClassesResponseClass {
|
export interface ClassesResponseClass {
|
||||||
|
@ -152,17 +150,6 @@ export class ClassroomComplianceAPIClient extends APIClient {
|
||||||
return new APIResponse(this.handleAPIResponseWithNoBody(promise))
|
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[]> {
|
getStudents(classId: number): APIResponse<Student[]> {
|
||||||
return super.get(`/classes/${classId}/students`)
|
return super.get(`/classes/${classId}/students`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||||
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
|
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
@ -15,20 +15,12 @@ const router = useRouter()
|
||||||
const cls: Ref<Class | null> = ref(null)
|
const cls: Ref<Class | null> = ref(null)
|
||||||
const notes: Ref<ClassNote[]> = ref([])
|
const notes: Ref<ClassNote[]> = ref([])
|
||||||
const noteContent: Ref<string> = 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 entriesTable = useTemplateRef('entries-table')
|
||||||
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
|
||||||
|
|
||||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
|
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadClass()
|
loadClass()
|
||||||
})
|
})
|
||||||
|
@ -39,8 +31,6 @@ function loadClass() {
|
||||||
if (result) {
|
if (result) {
|
||||||
cls.value = result
|
cls.value = result
|
||||||
refreshNotes()
|
refreshNotes()
|
||||||
scoreExpression.value = cls.value.scoreExpression
|
|
||||||
scorePeriod.value = cls.value.scorePeriod
|
|
||||||
} else {
|
} else {
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
|
@ -85,28 +75,6 @@ async function resetStudentDesks() {
|
||||||
// Reload the table!
|
// Reload the table!
|
||||||
await entriesTable.value?.loadEntries()
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div v-if="cls">
|
<div v-if="cls">
|
||||||
|
@ -133,56 +101,6 @@ function resetScoreParameters() {
|
||||||
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
|
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
|
||||||
</div>
|
</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. -->
|
<!-- Confirmation dialog used for attempts at deleting this class. -->
|
||||||
<ConfirmDialog ref="deleteClassDialog">
|
<ConfirmDialog ref="deleteClassDialog">
|
||||||
<p>
|
<p>
|
||||||
|
@ -193,10 +111,3 @@ function resetScoreParameters() {
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
|
||||||
.score-expression-variable {
|
|
||||||
font-family: 'SourceCodePro', monospace;
|
|
||||||
font-style: normal;
|
|
||||||
color: rgb(165, 210, 253);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ const authStore = useAuthStore()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!authStore.state" class="align-center">
|
<p v-if="!authStore.state" class="align-center">
|
||||||
Please <RouterLink class="link link-color" to="/login">log in</RouterLink> to view your applications.
|
Please <RouterLink to="/login">log in</RouterLink> to view your applications.
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
Loading…
Reference in New Issue