module api_modules.classroom_compliance; import handy_httpd; import handy_httpd.handlers.path_handler; import d2sqlite3; import slf4d; import std.typecons : Nullable; import std.datetime; import std.json; import std.algorithm; import std.array; import db; import data_utils; import api_modules.auth : User, getUserOrThrow; struct ClassroomComplianceClass { const ulong id; const ushort number; const string schoolYear; const ulong userId; } struct ClassroomComplianceStudent { const ulong id; const string name; const ulong classId; const ushort deskNumber; const bool removed; } struct ClassroomComplianceEntry { const ulong id; const ulong classId; const ulong studentId; const string date; const ulong createdAt; const bool absent; const string comment; } struct ClassroomComplianceEntryPhone { const ulong entryId; const bool compliant; } struct ClassroomComplianceEntryBehavior { const ulong entryId; const ubyte rating; } void registerApiEndpoints(PathHandler handler) { const ROOT_PATH = "/api/classroom-compliance"; handler.addMapping(Method.POST, ROOT_PATH ~ "/classes", &createClass); handler.addMapping(Method.GET, ROOT_PATH ~ "/classes", &getClasses); const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong"; handler.addMapping(Method.GET, CLASS_PATH, &getClass); handler.addMapping(Method.DELETE, CLASS_PATH, &deleteClass); handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent); handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents); const STUDENT_PATH = CLASS_PATH ~ "/students/:studentId:ulong"; handler.addMapping(Method.GET, STUDENT_PATH, &getStudent); handler.addMapping(Method.PUT, STUDENT_PATH, &updateStudent); handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent); handler.addMapping(Method.GET, STUDENT_PATH ~ "/entries", &getStudentEntries); handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries); handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries); } void createClass(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); struct ClassPayload { ushort number; string schoolYear; } auto payload = readJsonPayload!(ClassPayload)(ctx); const bool classNumberExists = canFind( db, "SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?", payload.number, payload.schoolYear, user.id ); if (classNumberExists) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("There is already a class with this number, for the same school year."); return; } auto stmt = db.prepare("INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?)"); stmt.bindAll(payload.number, payload.schoolYear, user.id); stmt.execute(); ulong classId = db.lastInsertRowid(); auto newClass = findOne!(ClassroomComplianceClass)( db, "SELECT * FROM classroom_compliance_class WHERE id = ? AND user_id = ?", classId, user.id ).orElseThrow(); writeJsonBody(ctx, newClass); } void getClasses(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto classes = findAll!(ClassroomComplianceClass)( db, "SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC", user.id ); writeJsonBody(ctx, classes); } void getClass(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto cls = getClassOrThrow(ctx, db, user); writeJsonBody(ctx, cls); } void deleteClass(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto cls = getClassOrThrow(ctx, db, user); db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id); } ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) { ulong classId = ctx.request.getPathParamAs!ulong("classId"); return findOne!(ClassroomComplianceClass)( db, "SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?", user.id, classId ).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); } void createStudent(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto cls = getClassOrThrow(ctx, db, user); struct StudentPayload { string name; ushort deskNumber; bool removed; } auto payload = readJsonPayload!(StudentPayload)(ctx); bool studentExists = canFind( db, "SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", payload.name, cls.id ); if (studentExists) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("A student with that name already exists in this class."); return; } bool deskAlreadyOccupied = payload.deskNumber != 0 && canFind( db, "SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?", cls.id, payload.deskNumber ); if (deskAlreadyOccupied) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("There is already a student assigned to that desk number."); } db.execute( "INSERT INTO classroom_compliance_student (name, class_id, desk_number, removed) VALUES (?, ?, ?, ?)", payload.name, cls.id, payload.deskNumber, payload.removed ); ulong studentId = db.lastInsertRowid(); auto student = findOne!(ClassroomComplianceStudent)( db, "SELECT * FROM classroom_compliance_student WHERE id = ?", studentId ).orElseThrow(); writeJsonBody(ctx, student); } void getStudents(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto cls = getClassOrThrow(ctx, db, user); auto students = findAll!(ClassroomComplianceStudent)( db, "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", cls.id ); writeJsonBody(ctx, students); } void getStudent(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto student = getStudentOrThrow(ctx, db, user); writeJsonBody(ctx, student); } void updateStudent(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto student = getStudentOrThrow(ctx, db, user); struct StudentUpdatePayload { string name; ushort deskNumber; bool removed; } auto payload = readJsonPayload!(StudentUpdatePayload)(ctx); // If there is nothing to update, quit. if ( payload.name == student.name && payload.deskNumber == student.deskNumber && payload.removed == student.removed ) return; // Check that the new name doesn't already exist. bool newNameExists = payload.name != student.name && canFind( db, "SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", payload.name, student.classId ); if (newNameExists) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("A student with that name already exists in this class."); return; } // Check that if a new desk number is assigned, that it's not already assigned to anyone else. bool newDeskOccupied = payload.deskNumber != 0 && payload.deskNumber != student.deskNumber && canFind( db, "SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?", student.classId, payload.deskNumber ); if (newDeskOccupied) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("That desk is already assigned to another student."); return; } db.execute( "UPDATE classroom_compliance_student SET name = ?, desk_number = ?, removed = ? WHERE id = ?", payload.name, payload.deskNumber, payload.removed, student.id ); auto updatedStudent = findOne!(ClassroomComplianceStudent)( db, "SELECT * FROM classroom_compliance_student WHERE id = ?", student.id ).orElseThrow(); writeJsonBody(ctx, updatedStudent); } void deleteStudent(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto student = getStudentOrThrow(ctx, db, user); db.execute( "DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?", student.id, student.classId ); } ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) { ulong classId = ctx.request.getPathParamAs!ulong("classId"); ulong studentId = ctx.request.getPathParamAs!ulong("studentId"); string query = " SELECT s.* FROM classroom_compliance_student s LEFT JOIN classroom_compliance_class c ON s.class_id = c.id WHERE s.id = ? AND s.class_id = ? AND c.user_id = ? "; return findOne!(ClassroomComplianceStudent)( db, query, studentId, classId, user.id ).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); } void getEntries(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto cls = getClassOrThrow(ctx, db, user); // Default to getting entries from the last 5 days. SysTime now = Clock.currTime(); Date toDate = Date(now.year, now.month, now.day); Date fromDate = toDate - days(4); if (ctx.request.queryParams.contains("to")) { try { toDate = Date.fromISOExtString(ctx.request.queryParams.getFirst("to").orElse("")); } catch (DateTimeException e) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Invalid \"to\" date."); return; } } if (ctx.request.queryParams.contains("from")) { try { fromDate = Date.fromISOExtString(ctx.request.queryParams.getFirst("from").orElse("")); } catch (DateTimeException e) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Invalid \"from\" date."); return; } } if (fromDate > toDate) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Invalid date range. From-date must be less than or equal to the to-date."); return; } if (toDate - fromDate > days(10)) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Date range is too big. Only ranges of 10 days or less are allowed."); return; } infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString()); // First prepare a list of all students, including ones which don't have any entries. ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)( db, "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", cls.id ); JSONValue[] studentObjects = students.map!((s) { JSONValue obj = JSONValue.emptyObject; obj.object["id"] = JSONValue(s.id); obj.object["deskNumber"] = JSONValue(s.deskNumber); obj.object["name"] = JSONValue(s.name); obj.object["removed"] = JSONValue(s.removed); obj.object["entries"] = JSONValue.emptyObject; obj.object["score"] = JSONValue(null); return obj; }).array; const entriesQuery = " SELECT entry.id, entry.date, entry.created_at, entry.absent, entry.comment, student.id, student.name, student.desk_number, student.removed, phone.compliant, behavior.rating FROM classroom_compliance_entry entry LEFT JOIN classroom_compliance_entry_phone phone ON phone.entry_id = entry.id LEFT JOIN classroom_compliance_entry_behavior behavior ON behavior.entry_id = entry.id 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 "; ResultRange entriesResult = db.execute(entriesQuery, cls.id, fromDate.toISOExtString(), toDate.toISOExtString()); // Serialize the results into a custom-formatted response object. foreach (row; entriesResult) { JSONValue entry = JSONValue.emptyObject; entry.object["id"] = JSONValue(row.peek!ulong(0)); entry.object["date"] = JSONValue(row.peek!string(1)); entry.object["createdAt"] = JSONValue(row.peek!ulong(2)); entry.object["absent"] = JSONValue(row.peek!bool(3)); entry.object["comment"] = JSONValue(row.peek!string(4)); JSONValue phone = JSONValue(null); JSONValue behavior = JSONValue(null); if (!entry.object["absent"].boolean()) { phone = JSONValue.emptyObject; phone.object["compliant"] = JSONValue(row.peek!bool(9)); behavior = JSONValue.emptyObject; behavior.object["rating"] = JSONValue(row.peek!ubyte(10)); } entry.object["phone"] = phone; entry.object["behavior"] = behavior; string dateStr = entry.object["date"].str(); // Find the student object this entry belongs to, then add it to their list. ulong studentId = row.peek!ulong(5); bool studentFound = false; foreach (idx, student; students) { if (student.id == studentId) { studentObjects[idx].object["entries"].object[dateStr] = entry; studentFound = true; break; } } if (!studentFound) { throw new Exception("Failed to find student."); } } // Find scores for each student for this timeframe. Optional!double[ulong] scores = getScores(db, cls.id, fromDate, toDate); foreach (studentId, score; scores) { JSONValue scoreValue = score.isNull ? JSONValue(null) : JSONValue(score.value); bool studentFound = false; foreach (idx, student; students) { if (studentId == student.id) { studentObjects[idx].object["score"] = scoreValue; studentFound = true; break; } } if (!studentFound) { throw new Exception("Failed to find student."); } } JSONValue response = JSONValue.emptyObject; // Provide the list of dates that we're providing data for, to make it easier for the frontend. response.object["dates"] = JSONValue.emptyArray; // Also fill in "null" for any students that don't have an entry on one of these days. Date d = fromDate; while (d <= toDate) { string dateStr = d.toISOExtString(); response.object["dates"].array ~= JSONValue(dateStr); foreach (studentObj; studentObjects) { if (dateStr !in studentObj.object["entries"].object) { studentObj.object["entries"].object[dateStr] = JSONValue(null); } } d += days(1); } response.object["students"] = JSONValue(studentObjects); string jsonStr = response.toJSON(); ctx.response.writeBodyString(jsonStr, "application/json"); } void saveEntries(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto cls = getClassOrThrow(ctx, db, user); JSONValue bodyContent = ctx.request.readBodyAsJson(); db.begin(); 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(db, cls.id, studentId, dateStr); continue; } Optional!ClassroomComplianceEntry existingEntry = findOne!(ClassroomComplianceEntry)( db, "SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", 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(db, 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(db, cls.id, studentId, dateStr, entryId, entry); } } } db.commit(); } catch (HttpStatusException e) { db.rollback(); ctx.response.status = e.status; ctx.response.writeBodyString(e.message); } catch (JSONException e) { db.rollback(); ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Invalid JSON payload."); warn(e); } catch (Exception e) { db.rollback(); ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR; ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg); error(e); } } private void deleteEntry( ref Database db, ulong classId, ulong studentId, string dateStr ) { db.execute( "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( ref Database db, ulong classId, ulong studentId, string dateStr, JSONValue payload ) { ulong createdAt = getUnixTimestampMillis(); bool absent = payload.object["absent"].boolean; string comment = payload.object["comment"].str; if (comment is null) comment = ""; db.execute( "INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent, comment) VALUES (?, ?, ?, ?, ?, ?)", classId, studentId, dateStr, createdAt, absent, comment ); if (!absent) { ulong entryId = db.lastInsertRowid(); if ("phone" !in payload.object || payload.object["phone"].type != JSONType.object) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing phone data."); } if ("behavior" !in payload.object || payload.object["behavior"].type != JSONType.object) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing behavior data."); } bool phoneCompliance = payload.object["phone"].object["compliant"].boolean; db.execute( "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)", entryId, phoneCompliance ); ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer; db.execute( "INSERT INTO classroom_compliance_entry_behavior (entry_id, rating) VALUES (?, ?)", entryId, behaviorRating ); } infoF!"Created new entry for student %d: %s"(studentId, payload); } private void updateEntry( ref Database db, 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 = ""; db.execute( "UPDATE classroom_compliance_entry SET absent = ?, comment = ? WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?", absent, comment, classId, studentId, dateStr, entryId ); if (absent) { db.execute( "DELETE FROM classroom_compliance_entry_phone WHERE entry_id = ?", entryId ); db.execute( "DELETE FROM classroom_compliance_entry_behavior WHERE entry_id = ?", entryId ); } else { bool phoneCompliant = obj.object["phone"].object["compliant"].boolean; bool phoneDataExists = canFind( db, "SELECT * FROM classroom_compliance_entry_phone WHERE entry_id = ?", entryId ); if (phoneDataExists) { db.execute( "UPDATE classroom_compliance_entry_phone SET compliant = ? WHERE entry_id = ?", phoneCompliant, entryId ); } else { db.execute( "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)", entryId, phoneCompliant ); } ubyte behaviorRating = cast(ubyte) obj.object["behavior"].object["rating"].integer; bool behaviorDataExists = canFind( db, "SELECT * FROM classroom_compliance_entry_behavior WHERE entry_id = ?", entryId ); if (behaviorDataExists) { db.execute( "UPDATE classroom_compliance_entry_behavior SET rating = ? WHERE entry_id = ?", behaviorRating, entryId ); } else { db.execute( "INSERT INTO classroom_compliance_entry_behavior (entry_id, rating) VALUES (?, ?)", entryId, behaviorRating ); } } infoF!"Updated entry %d"(entryId); } Optional!double[ulong] getScores( ref Database db, ulong classId, Date fromDate, Date toDate ) { infoF!"Getting scores from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString()); // First populate all students with an initial "null" score. Optional!double[ulong] scores; ResultRange studentsResult = db.execute( "SELECT id FROM classroom_compliance_student WHERE class_id = ?", classId ); foreach (row; studentsResult) { scores[row.peek!ulong(0)] = Optional!double.empty; } const query = " SELECT e.student_id, COUNT(e.id) AS entry_count, SUM(e.absent) AS absence_count, SUM(NOT p.compliant) AS phone_noncompliance_count, SUM(b.rating = 3) AS behavior_good, SUM(b.rating = 2) AS behavior_mediocre, SUM(b.rating = 1) AS behavior_poor FROM classroom_compliance_entry e LEFT JOIN classroom_compliance_entry_phone p ON p.entry_id = e.id LEFT JOIN classroom_compliance_entry_behavior b ON b.entry_id = e.id WHERE e.date >= ? AND e.date <= ? AND e.class_id = ? GROUP BY e.student_id "; ResultRange result = db.execute( query, fromDate.toISOExtString(), toDate.toISOExtString(), classId ); foreach (row; result) { ulong studentId = row.peek!ulong(0); uint entryCount = row.peek!uint(1); uint absenceCount = row.peek!uint(2); uint phoneNonComplianceCount = row.peek!uint(3); uint behaviorGoodCount = row.peek!uint(4); uint behaviorMediocreCount = row.peek!uint(5); uint behaviorPoorCount = row.peek!uint(6); scores[studentId] = calculateScore( entryCount, absenceCount, phoneNonComplianceCount, behaviorGoodCount, behaviorMediocreCount, behaviorPoorCount ); } return scores; } 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); } void getStudentEntries(ref HttpRequestContext ctx) { auto db = getDb(); User user = getUserOrThrow(ctx, db); auto student = getStudentOrThrow(ctx, db, user); const query = " SELECT e.id, e.date, e.created_at, e.absent, e.comment, p.compliant, b.rating FROM classroom_compliance_entry e LEFT JOIN classroom_compliance_entry_phone p ON p.entry_id = e.id LEFT JOIN classroom_compliance_entry_behavior b ON b.entry_id = e.id WHERE e.student_id = ? ORDER BY e.date DESC "; JSONValue response = JSONValue.emptyArray; foreach (row; db.execute(query, student.id)) { JSONValue e = JSONValue.emptyObject; bool absent = row.peek!bool(3); e.object["id"] = JSONValue(row.peek!ulong(0)); e.object["date"] = JSONValue(row.peek!string(1)); e.object["createdAt"] = JSONValue(row.peek!ulong(2)); e.object["absent"] = JSONValue(absent); e.object["comment"] = JSONValue(row.peek!string(4)); if (absent) { e.object["phone"] = JSONValue(null); e.object["behavior"] = JSONValue(null); } else { JSONValue phone = JSONValue.emptyObject; phone.object["compliant"] = JSONValue(row.peek!bool(5)); e.object["phone"] = phone; JSONValue behavior = JSONValue.emptyObject; behavior.object["rating"] = JSONValue(row.peek!ubyte(6)); e.object["behavior"] = behavior; } response.array ~= e; } ctx.response.writeBodyString(response.toJSON(), "application/json"); }