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 api_modules.classroom_compliance.score; import db; import data_utils; struct EntriesTableEntryChecklistItem { string item; bool checked; string category; } struct EntriesTableEntry { Date date; ulong createdAt; bool absent; string comment; EntriesTableEntryChecklistItem[] checklistItems; JSONValue toJsonObj() const { JSONValue obj = JSONValue.emptyObject; obj.object["date"] = JSONValue(date.toISOExtString()); obj.object["createdAt"] = JSONValue(createdAt); obj.object["absent"] = JSONValue(absent); obj.object["comment"] = JSONValue(comment); obj.object["checklistItems"] = JSONValue.emptyArray; foreach (ck; checklistItems) { JSONValue ckObj = JSONValue.emptyObject; ckObj.object["item"] = JSONValue(ck.item); ckObj.object["checked"] = JSONValue(ck.checked); ckObj.object["category"] = JSONValue(ck.category); obj.object["checklistItems"].array ~= ckObj; } return obj; } } struct EntriesTableStudentResponse { ulong id; ulong classId; 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["classId"] = JSONValue(classId); 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.classId, s.name, s.deskNumber, s.removed, null, Optional!double.empty )).array; const entriesQuery = import("source/api_modules/classroom_compliance/queries/find_entries_by_class.sql"); 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(); ClassroomComplianceStudent student; EntriesTableEntry entry; bool hasNextRow = rs.next(); while (hasNextRow) { // Parse the basic data from the query. student.id = rs.getUlong(1); student.name = rs.getString(2); student.classId = cls.id; student.deskNumber = rs.getUshort(3); student.removed = rs.getBoolean(4); entry.date = rs.getDate(5); entry.createdAt = rs.getUlong(6); entry.absent = rs.getBoolean(7); entry.comment = rs.getString(8); bool hasChecklistItem = !rs.isNull(9); if (hasChecklistItem) { entry.checklistItems ~= EntriesTableEntryChecklistItem( rs.getString(9), rs.getBoolean(10), rs.getString(11) ); } // Load in the next row, and if it's the end of the result set or a new entry, we save this one. hasNextRow = rs.next(); bool shouldSaveEntry = !hasNextRow || ( rs.getUlong(1) != student.id || rs.getDate(5) != entry.date ); if (shouldSaveEntry) { // Save the data for the current student and entry, including all checklist items. // Then proceed to read the next item. string dateStr = entry.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] = entry; 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.classId, student.name, student.deskNumber, student.removed, [dateStr: entry], Optional!double.empty ); } // Finally, reset the entry's list of checklist items. entry.checklistItems = []; } } // Find scores for each student for this timeframe. Optional!double[ulong] scores = getScores(conn, cls.id, dateRange.to); 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); if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived."); 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) { // Always start by deleting the existing entry to overwrite it with the new one. deleteEntry(conn, cls.id, studentId, dateStr); if (!entry.isNull) { insertEntry(conn, cls.id, studentId, dateStr, 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 ); } private void insertEntry( 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 = ""; EntriesTableEntryChecklistItem[] checklistItems; if ("checklistItems" in payload.object) { checklistItems = payload.object["checklistItems"].array .map!((obj) { EntriesTableEntryChecklistItem ck; ck.item = obj.object["item"].str; ck.checked = obj.object["checked"].boolean; ck.category = obj.object["category"].str; return ck; }) .array; } // If absent, ensure no checklist items may be checked. if (absent) { foreach (ref ck; checklistItems) { ck.checked = false; } } // Do the main insert first. const query = " INSERT INTO classroom_compliance_entry (class_id, student_id, date, absent, comment) 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); ps.executeUpdate(); // Now insert checklist items, if any. if (checklistItems.length > 0) { const ckQuery = " INSERT INTO classroom_compliance_entry_checklist_item (class_id, student_id, date, item, checked, category) VALUES (?, ?, ?, ?, ?, ?)"; PreparedStatement ckPs = conn.prepareStatement(ckQuery); scope(exit) ckPs.close(); foreach (ck; checklistItems) { ckPs.setUlong(1, classId); ckPs.setUlong(2, studentId); ckPs.setString(3, dateStr); ckPs.setString(4, ck.item); ckPs.setBoolean(5, ck.checked); ckPs.setString(6, ck.category); ckPs.executeUpdate(); } } }