module api_modules.classroom_compliance.api_entry; import handy_httpd; import handy_httpd.components.optional; import ddbc; import std.datetime; import std.json; import std.algorithm : map; import std.array; import slf4d; import api_modules.auth; import api_modules.classroom_compliance.model; import api_modules.classroom_compliance.util; import db; import data_utils; struct EntriesTableEntry { ulong id; Date date; ulong createdAt; bool absent; string comment; Optional!bool phoneCompliant; Optional!ubyte behaviorRating; JSONValue toJsonObj() const { JSONValue obj = JSONValue.emptyObject; obj.object["id"] = JSONValue(id); obj.object["date"] = JSONValue(date.toISOExtString()); obj.object["createdAt"] = JSONValue(createdAt); obj.object["absent"] = JSONValue(absent); obj.object["comment"] = JSONValue(comment); if (absent) { obj.object["phoneCompliant"] = JSONValue(null); obj.object["behaviorRating"] = JSONValue(null); } else { obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value); obj.object["behaviorRating"] = JSONValue(behaviorRating.value); } return obj; } } struct EntriesTableStudentResponse { ulong id; string name; ushort deskNumber; bool removed; EntriesTableEntry[string] entries; Optional!double score; JSONValue toJsonObj() const { JSONValue obj = JSONValue.emptyObject; obj.object["id"] = JSONValue(id); obj.object["name"] = JSONValue(name); obj.object["deskNumber"] = JSONValue(deskNumber); obj.object["removed"] = JSONValue(removed); JSONValue entriesSet = JSONValue.emptyObject; foreach (dateStr, entry; entries) { entriesSet.object[dateStr] = entry.toJsonObj(); } obj.object["entries"] = entriesSet; if (score.isNull) { obj.object["score"] = JSONValue(null); } else { obj.object["score"] = JSONValue(score.value); } return obj; } } struct EntriesTableResponse { EntriesTableStudentResponse[] students; string[] dates; JSONValue toJsonObj() const { JSONValue obj = JSONValue.emptyObject; obj.object["students"] = JSONValue(students.map!(s => s.toJsonObj()).array); obj.object["dates"] = JSONValue(dates); return obj; } } /** * Main endpoint that supplies data for the app's "entries" table, which shows * all data about all students in a class, usually for a selected week. Here, * we need to provide a list of students which will be treated as rows by the * table, and then for each student, an entry object for each date in the * requested date range. * Params: * ctx = The request context. */ void getEntries(ref HttpRequestContext ctx) { Connection conn = getDb(); scope(exit) conn.close(); User user = getUserOrThrow(ctx, conn); auto cls = getClassOrThrow(ctx, conn, user); DateRange dateRange = parseDateRangeParams(ctx); // First prepare a list of all students, including ones which don't have any entries. ClassroomComplianceStudent[] students = findAll( conn, "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", &ClassroomComplianceStudent.parse, cls.id ); EntriesTableStudentResponse[] studentObjects = students.map!(s => EntriesTableStudentResponse( s.id, s.name, s.deskNumber, s.removed, null, Optional!double.empty )).array; const entriesQuery = " SELECT entry.id, entry.date, entry.created_at, entry.absent, entry.comment, entry.phone_compliant, entry.behavior_rating, student.id, student.name, student.desk_number, student.removed FROM classroom_compliance_entry entry LEFT JOIN classroom_compliance_student student ON student.id = entry.student_id WHERE entry.class_id = ? AND entry.date >= ? AND entry.date <= ? ORDER BY student.id ASC, entry.date ASC "; PreparedStatement ps = conn.prepareStatement(entriesQuery); scope(exit) ps.close(); ps.setUlong(1, cls.id); ps.setDate(2, dateRange.from); ps.setDate(3, dateRange.to); ResultSet rs = ps.executeQuery(); scope(exit) rs.close(); foreach (DataSetReader r; rs) { // Parse the basic data from the query. const absent = r.getBoolean(4); Optional!bool phoneCompliant = absent ? Optional!bool.empty : Optional!bool.of(r.getBoolean(6)); Optional!ubyte behaviorRating = absent ? Optional!ubyte.empty : Optional!ubyte.of(r.getUbyte(7)); EntriesTableEntry entryData = EntriesTableEntry( r.getUlong(1), r.getDate(2), r.getUlong(3), r.getBoolean(4), r.getString(5), phoneCompliant, behaviorRating ); ClassroomComplianceStudent student = ClassroomComplianceStudent( r.getUlong(8), r.getString(9), cls.id, r.getUshort(10), r.getBoolean(11) ); string dateStr = entryData.date.toISOExtString(); // Find the student object this entry belongs to, then add it to their list. bool studentFound = false; foreach (ref studentObj; studentObjects) { if (studentObj.id == student.id) { studentObj.entries[dateStr] = entryData; studentFound = true; break; } } if (!studentFound) { // The student isn't in our list of original students from the // class, so it's a student who has since moved to another class. // Their data should still be shown, so add the student here. studentObjects ~= EntriesTableStudentResponse( student.id, student.name, student.deskNumber, student.removed, [dateStr: entryData], Optional!double.empty ); } } // Find scores for each student for this timeframe. Optional!double[ulong] scores = getScores(conn, cls.id, dateRange); foreach (studentId, score; scores) { bool studentFound = false; foreach (ref studentObj; studentObjects) { if (studentObj.id == studentId) { studentObj.score = score; studentFound = true; break; } } if (!studentFound) { throw new Exception("Failed to find student for which a score was calculated."); } } // Prepare the final response to the client: EntriesTableResponse response; Date d = dateRange.from; while (d <= dateRange.to) { string dateStr = d.toISOExtString(); response.dates ~= dateStr; d += days(1); } response.students = studentObjects; JSONValue responseObj = response.toJsonObj(); // Go back and add null to any dates any student is missing an entry for. foreach (ref studentObj; responseObj.object["students"].array) { foreach (dateStr; response.dates) { if (dateStr !in studentObj.object["entries"].object) { studentObj.object["entries"].object[dateStr] = JSONValue(null); } } } ctx.response.writeBodyString(responseObj.toJSON(), "application/json"); } /** * Endpoint for the user to save changes to any entries they've edited. The * user provides a JSON payload containing the updated entries, and we go * through and perform updates to the database to match the desired state. * Params: * ctx = The request context. */ void saveEntries(ref HttpRequestContext ctx) { Connection conn = getDb(); conn.setAutoCommit(false); scope(exit) conn.close(); User user = getUserOrThrow(ctx, conn); auto cls = getClassOrThrow(ctx, conn, user); JSONValue bodyContent = ctx.request.readBodyAsJson(); try { foreach (JSONValue studentObj; bodyContent.object["students"].array) { ulong studentId = studentObj.object["id"].integer(); JSONValue entries = studentObj.object["entries"]; foreach (string dateStr, JSONValue entry; entries.object) { if (entry.isNull) { deleteEntry(conn, cls.id, studentId, dateStr); continue; } Optional!ClassroomComplianceEntry existingEntry = findOne( conn, "SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", &ClassroomComplianceEntry.parse, cls.id, studentId, dateStr ); ulong entryId = entry.object["id"].integer(); bool creatingNewEntry = entryId == 0; if (creatingNewEntry) { if (!existingEntry.isNull) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Cannot create a new entry when one already exists."); return; } insertNewEntry(conn, cls.id, studentId, dateStr, entry); } else { if (existingEntry.isNull) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Cannot update an entry which doesn't exist."); return; } updateEntry(conn, cls.id, studentId, dateStr, entryId, entry); } } } conn.commit(); } catch (HttpStatusException e) { conn.rollback(); ctx.response.status = e.status; ctx.response.writeBodyString(e.message); } catch (JSONException e) { conn.rollback(); ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Invalid JSON payload."); warn(e); } catch (Exception e) { conn.rollback(); ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR; ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg); error(e); } } private void deleteEntry( Connection conn, ulong classId, ulong studentId, string dateStr ) { update( conn, "DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", classId, studentId, dateStr ); infoF!"Deleted entry for student %s on %s"(studentId, dateStr); } private void insertNewEntry( Connection conn, ulong classId, ulong studentId, string dateStr, JSONValue payload ) { bool absent = payload.object["absent"].boolean; string comment = payload.object["comment"].str; if (comment is null) comment = ""; Optional!bool phoneCompliant = Optional!bool.empty; Optional!ubyte behaviorRating = Optional!ubyte.empty; if (!absent) { phoneCompliant = Optional!bool.of(payload.object["phoneCompliant"].boolean); behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer); } const query = " INSERT INTO classroom_compliance_entry (class_id, student_id, date, absent, comment, phone_compliant, behavior_rating) VALUES (?, ?, ?, ?, ?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(query); scope(exit) ps.close(); ps.setUlong(1, classId); ps.setUlong(2, studentId); ps.setString(3, dateStr); ps.setBoolean(4, absent); ps.setString(5, comment); if (absent) { ps.setNull(6); ps.setNull(7); } else { ps.setBoolean(6, phoneCompliant.value); ps.setUbyte(7, behaviorRating.value); } ps.executeUpdate(); infoF!"Created new entry for student %d: %s"(studentId, payload); } private void updateEntry( Connection conn, ulong classId, ulong studentId, string dateStr, ulong entryId, JSONValue obj ) { bool absent = obj.object["absent"].boolean; string comment = obj.object["comment"].str; if (comment is null) comment = ""; Optional!bool phoneCompliant = Optional!bool.empty; Optional!ubyte behaviorRating = Optional!ubyte.empty; if (!absent) { phoneCompliant = Optional!bool.of(obj.object["phoneCompliant"].boolean); behaviorRating = Optional!ubyte.of(cast(ubyte) obj.object["behaviorRating"].integer); } const query = " UPDATE classroom_compliance_entry SET absent = ?, comment = ?, phone_compliant = ?, behavior_rating = ? WHERE class_id = ? AND student_id = ? AND date = ? AND id = ? "; PreparedStatement ps = conn.prepareStatement(query); scope(exit) ps.close(); ps.setBoolean(1, absent); ps.setString(2, comment); if (absent) { ps.setNull(3); ps.setNull(4); } else { ps.setBoolean(3, phoneCompliant.value); ps.setUbyte(4, behaviorRating.value); } ps.setUlong(5, classId); ps.setUlong(6, studentId); ps.setString(7, dateStr); ps.setUlong(8, entryId); ps.executeUpdate(); 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); }