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 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.PUT, STUDENT_PATH, &updateStudent); handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent); handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &createEntry); handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries); } 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 this 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; } 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("Student with that name already exists."); 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."); } db.execute( "INSERT INTO classroom_compliance_student (name, class_id, desk_number) VALUES (?, ?)", payload.name, cls.id, payload.deskNumber ); 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 updateStudent(ref HttpRequestContext ctx) { User user = getUserOrThrow(ctx); auto student = getStudentOrThrow(ctx, user); struct StudentUpdatePayload { string name; ushort deskNumber; } auto payload = readJsonPayload!(StudentUpdatePayload)(ctx); // If there is nothing to update, quit. if ( payload.name == student.name && payload.deskNumber == student.deskNumber ) return; // Check that the new name doesn't already exist. auto db = getDb(); bool newNameExists = 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("Student with that name already exists."); return; } // Check that if a new desk number is assigned, that it's not already assigned to anyone else. bool newDeskOccupied = payload.deskNumber != 0 && 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 = ? WHERE id = ?", payload.name, payload.deskNumber, 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 createEntry(ref HttpRequestContext ctx) { User user = getUserOrThrow(ctx); auto cls = getClassOrThrow(ctx, user); struct EntryPhonePayload { bool compliant; } struct EntryBehaviorPayload { int rating; Nullable!string comment; } struct EntryPayload { ulong studentId; string date; bool absent; Nullable!EntryPhonePayload phoneCompliance; Nullable!EntryBehaviorPayload behaviorCompliance; } auto payload = readJsonPayload!(EntryPayload)(ctx); auto db = getDb(); bool entryAlreadyExists = canFind( db, "SELECT id FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", cls.id, payload.studentId, payload.date ); if (entryAlreadyExists) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("An entry already exists for this student and date."); return; } // Insert the entry and its attached entities in a transaction. db.begin(); try { db.execute( "INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent) VALUES (?, ?, ?, ?, ?)", cls.id, payload.studentId, payload.date, getUnixTimestampMillis(), payload.absent ); ulong entryId = db.lastInsertRowid(); if (!payload.absent && !payload.phoneCompliance.isNull) { db.execute( "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)", entryId, payload.phoneCompliance.get().compliant ); } if (!payload.absent && !payload.behaviorCompliance.isNull) { Nullable!string comment = payload.behaviorCompliance.get().comment; if (!comment.isNull && (comment.get() is null || comment.get().length == 0)) { comment.nullify(); } db.execute( "INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment) VALUES (?, ?, ?)", entryId, payload.behaviorCompliance.get().rating, comment ); } db.commit(); } catch (Exception e) { db.rollback(); } } 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(); 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 entry.date ASC, student.desk_number ASC, student.name ASC "; ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString()); // Serialize the results into a custom-formatted response object. import std.json; JSONValue response = JSONValue.emptyObject; JSONValue[ulong] studentObjects; foreach (row; result) { ulong studentId = row.peek!ulong(4); if (studentId !in studentObjects) { JSONValue student = JSONValue.emptyObject; student.object["id"] = JSONValue(row.peek!ulong(4)); student.object["name"] = JSONValue(row.peek!string(5)); student.object["deskNumber"] = JSONValue(row.peek!ushort(6)); student.object["removed"] = JSONValue(row.peek!bool(7)); student.object["entries"] = JSONValue.emptyObject; studentObjects[studentId] = student; } JSONValue studentObj = studentObjects[studentId]; 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!string(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(); studentObj.object["entries"].object[dateStr] = entry; } // 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.values); string jsonStr = response.toJSON(); ctx.response.writeBodyString(jsonStr, "application/json"); }