diff --git a/api/dub.json b/api/dub.json index ca4ed85..5562374 100644 --- a/api/dub.json +++ b/api/dub.json @@ -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.", diff --git a/api/dub.selections.json b/api/dub.selections.json index fd1d65f..4f4c3f3 100644 --- a/api/dub.selections.json +++ b/api/dub.selections.json @@ -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", diff --git a/api/schema/classroom_compliance.sql b/api/schema/classroom_compliance.sql index b1315e2..1e20a4c 100644 --- a/api/schema/classroom_compliance.sql +++ b/api/schema/classroom_compliance.sql @@ -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) ); diff --git a/api/source/api_modules/classroom_compliance/api.d b/api/source/api_modules/classroom_compliance/api.d index ee24897..864e9ca 100644 --- a/api/source/api_modules/classroom_compliance/api.d +++ b/api/source/api_modules/classroom_compliance/api.d @@ -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); diff --git a/api/source/api_modules/classroom_compliance/api_class.d b/api/source/api_modules/classroom_compliance/api_class.d index 31568ac..b67a9a6 100644 --- a/api/source/api_modules/classroom_compliance/api_class.d +++ b/api/source/api_modules/classroom_compliance/api_class.d @@ -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); +} diff --git a/api/source/api_modules/classroom_compliance/api_entry.d b/api/source/api_modules/classroom_compliance/api_entry.d index 4c85599..66b90fb 100644 --- a/api/source/api_modules/classroom_compliance/api_entry.d +++ b/api/source/api_modules/classroom_compliance/api_entry.d @@ -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); -} diff --git a/api/source/api_modules/classroom_compliance/model.d b/api/source/api_modules/classroom_compliance/model.d index 88b122c..40c2169 100644 --- a/api/source/api_modules/classroom_compliance/model.d +++ b/api/source/api_modules/classroom_compliance/model.d @@ -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) ); } } diff --git a/api/source/api_modules/classroom_compliance/score.d b/api/source/api_modules/classroom_compliance/score.d new file mode 100644 index 0000000..896f158 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/score.d @@ -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 + ])); +} diff --git a/api/source/api_modules/classroom_compliance/score_eval.d b/api/source/api_modules/classroom_compliance/score_eval.d new file mode 100644 index 0000000..1f3db47 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/score_eval.d @@ -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); + } +} diff --git a/app/src/api/classroom_compliance.ts b/app/src/api/classroom_compliance.ts index 51163b3..82395f9 100644 --- a/app/src/api/classroom_compliance.ts +++ b/app/src/api/classroom_compliance.ts @@ -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 { + return super.put(`/classes/${classId}/score-parameters`, { + scoreExpression: scoreExpression, + scorePeriod: scorePeriod, + }) + } + getStudents(classId: number): APIResponse { return super.get(`/classes/${classId}/students`) } diff --git a/app/src/apps/classroom_compliance/ClassView.vue b/app/src/apps/classroom_compliance/ClassView.vue index 2d4a9d3..b707b14 100644 --- a/app/src/apps/classroom_compliance/ClassView.vue +++ b/app/src/apps/classroom_compliance/ClassView.vue @@ -1,6 +1,6 @@ +