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.format; 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; } struct ClassroomComplianceEntryPhone { const ulong entryId; const bool compliant; } struct ClassroomComplianceEntryBehavior { const ulong entryId; const ubyte rating; const string comment; } 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, CLASS_PATH ~ "/entries", &getEntries); handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries); } void createClass(ref HttpRequestContext ctx) { User user = getUserOrThrow(ctx); struct ClassPayload { ushort number; string schoolYear; } auto payload = readJsonPayload!(ClassPayload)(ctx); auto db = getDb(); 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) { User user = getUserOrThrow(ctx); auto db = getDb(); 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) { User user = getUserOrThrow(ctx); auto cls = getClassOrThrow(ctx, user); writeJsonBody(ctx, cls); } void deleteClass(ref HttpRequestContext ctx) { User user = getUserOrThrow(ctx); auto cls = getClassOrThrow(ctx, user); auto db = getDb(); db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id); } ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, in User user) { ulong classId = ctx.request.getPathParamAs!ulong("classId"); auto db = getDb(); 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) { User user = getUserOrThrow(ctx); auto cls = getClassOrThrow(ctx, user); struct StudentPayload { string name; ushort deskNumber; bool removed; } auto payload = readJsonPayload!(StudentPayload)(ctx); auto db = getDb(); 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) { User user = getUserOrThrow(ctx); auto cls = getClassOrThrow(ctx, user); auto db = getDb(); 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) { User user = getUserOrThrow(ctx); auto student = getStudentOrThrow(ctx, user); writeJsonBody(ctx, student); } void updateStudent(ref HttpRequestContext ctx) { User user = getUserOrThrow(ctx); auto student = getStudentOrThrow(ctx, 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. auto db = getDb(); 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) { User user = getUserOrThrow(ctx); auto student = getStudentOrThrow(ctx, user); auto db = getDb(); db.execute( "DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?", student.id, student.classId ); } ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User user) { ulong classId = ctx.request.getPathParamAs!ulong("classId"); ulong studentId = ctx.request.getPathParamAs!ulong("studentId"); auto db = getDb(); 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) { User user = getUserOrThrow(ctx); auto cls = getClassOrThrow(ctx, 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; } } infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString()); auto db = getDb(); 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; return obj; }).array; const query = " SELECT entry.id, entry.date, entry.created_at, entry.absent, student.id, student.name, student.desk_number, student.removed, phone.compliant, behavior.rating, behavior.comment 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 result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString()); // Serialize the results into a custom-formatted response object. foreach (row; result) { 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)); JSONValue phone = JSONValue(null); JSONValue behavior = JSONValue(null); if (!entry.object["absent"].boolean()) { phone = JSONValue.emptyObject; phone.object["compliant"] = JSONValue(row.peek!bool(8)); behavior = JSONValue.emptyObject; behavior.object["rating"] = JSONValue(row.peek!ubyte(9)); behavior.object["comment"] = JSONValue(row.peek!string(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(4); 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."); } } 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) { User user = getUserOrThrow(ctx); auto cls = getClassOrThrow(ctx, user); JSONValue bodyContent = ctx.request.readBodyAsJson(); auto db = getDb(); db.begin(); 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) { db.execute( "DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", cls.id, studentId, dateStr ); infoF!"Deleted entry for student %s on %s"(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( format!"Cannot create a new entry for student %d on %s when one already exists."( studentId, dateStr ) ); return; } insertNewEntry(db, cls.id, studentId, dateStr, entry); } else { if (existingEntry.isNull) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString( format!"Cannot update entry %d because it doesn't exist."( entryId ) ); return; } updateEntry(db, cls.id, studentId, dateStr, entryId, entry); } } } db.commit(); } private void insertNewEntry( ref Database db, ulong classId, ulong studentId, string dateStr, JSONValue payload ) { ulong createdAt = getUnixTimestampMillis(); bool absent = payload.object["absent"].boolean; db.execute( "INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent) VALUES (?, ?, ?, ?, ?)", classId, studentId, dateStr, createdAt, absent ); 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, comment) 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; db.execute( "UPDATE classroom_compliance_entry SET absent = ? WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?", absent, 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); }