diff --git a/api/schema/classroom_compliance.sql b/api/schema/classroom_compliance.sql index 307b4a8..da3a655 100644 --- a/api/schema/classroom_compliance.sql +++ b/api/schema/classroom_compliance.sql @@ -32,7 +32,6 @@ CREATE TABLE classroom_compliance_student_label ( ); CREATE TABLE classroom_compliance_entry ( - id BIGSERIAL PRIMARY KEY, class_id BIGINT NOT NULL REFERENCES classroom_compliance_class(id) ON UPDATE CASCADE ON DELETE CASCADE, @@ -44,23 +43,20 @@ CREATE TABLE classroom_compliance_entry ( DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000, absent BOOLEAN NOT NULL DEFAULT FALSE, comment VARCHAR(2000) NOT NULL DEFAULT '', - phone_compliant BOOLEAN NULL DEFAULT NULL, - classroom_readiness BOOLEAN NULL DEFAULT NULL, - behavior_rating INT NULL DEFAULT NULL, - CONSTRAINT absence_nulls_check CHECK ( - (absent AND classroom_readiness IS NULL AND behavior_rating IS NULL) OR - (NOT absent AND classroom_readiness IS NOT NULL AND behavior_rating IS NOT NULL) - ), - CONSTRAINT unique_entry_per_date - UNIQUE(class_id, student_id, date) + PRIMARY KEY (class_id, student_id, date) ); -CREATE TABLE classroom_compliance_entry_comment_checklist ( - entry_id BIGINT NOT NULL - REFERENCES classroom_compliance_entry(id) - ON UPDATE CASCADE ON DELETE CASCADE, +CREATE TABLE classroom_compliance_entry_checklist_item ( + class_id BIGINT NOT NULL, + student_id BIGINT NOT NULL, + date DATE NOT NULL, item VARCHAR(2000) NOT NULL, - PRIMARY KEY (entry_id, item) + checked BOOLEAN NOT NULL DEFAULT FALSE, + category VARCHAR(255) NOT NULL, + PRIMARY KEY (class_id, student_id, date, item, category), + FOREIGN KEY (class_id, student_id, date) + REFERENCES classroom_compliance_entry(class_id, student_id, date) + ON UPDATE CASCADE ON DELETE CASCADE ); CREATE TABLE classroom_compliance_class_note ( diff --git a/api/source/api_modules/classroom_compliance/api_class.d b/api/source/api_modules/classroom_compliance/api_class.d index 32ef21c..dd8ff35 100644 --- a/api/source/api_modules/classroom_compliance/api_class.d +++ b/api/source/api_modules/classroom_compliance/api_class.d @@ -50,7 +50,7 @@ void getClasses(ref HttpRequestContext ctx) { SELECT c.id, c.number, c.school_year, c.archived, COUNT(DISTINCT s.id) AS student_count, - COUNT(DISTINCT e.id) AS entry_count, + COUNT(DISTINCT (e.student_id, e.date)) AS entry_count, MAX(e.date) AS last_entry_date FROM classroom_compliance_class c LEFT JOIN classroom_compliance_student s ON c.id = s.class_id diff --git a/api/source/api_modules/classroom_compliance/api_entry.d b/api/source/api_modules/classroom_compliance/api_entry.d index 30a0e57..22153cc 100644 --- a/api/source/api_modules/classroom_compliance/api_entry.d +++ b/api/source/api_modules/classroom_compliance/api_entry.d @@ -16,39 +16,32 @@ import api_modules.classroom_compliance.score; import db; import data_utils; +struct EntriesTableEntryChecklistItem { + string item; + bool checked; + string category; +} + struct EntriesTableEntry { - ulong id; Date date; ulong createdAt; bool absent; string comment; - string[] checklistItems; - Optional!bool phoneCompliant; - Optional!bool classroomReadiness; - Optional!ubyte behaviorRating; + EntriesTableEntryChecklistItem[] checklistItems; 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); obj.object["checklistItems"] = JSONValue.emptyArray; foreach (ck; checklistItems) { - obj.object["checklistItems"].array ~= JSONValue(ck); - } - if (absent) { - obj.object["phoneCompliant"] = JSONValue(null); - obj.object["classroomReadiness"] = JSONValue(null); - obj.object["behaviorRating"] = JSONValue(null); - } else { - JSONValue pcValue = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value); - JSONValue crValue = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value); - JSONValue bValue = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value); - obj.object["phoneCompliant"] = pcValue; - obj.object["classroomReadiness"] = crValue; - obj.object["behaviorRating"] = bValue; + 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; } @@ -129,38 +122,7 @@ void getEntries(ref HttpRequestContext ctx) { Optional!double.empty )).array; - const entriesQuery = " - SELECT - entry.id, - entry.date, - entry.created_at, - entry.absent, - entry.comment, - ( - SELECT STRING_AGG(ck.item, '|||') - FROM classroom_compliance_entry_comment_checklist ck - WHERE ck.entry_id = entry.id - - ), - entry.phone_compliant, - entry.classroom_readiness, - entry.behavior_rating, - student.id, - student.name, - student.desk_number, - student.removed, - student.class_id - 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 - "; + 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); @@ -168,65 +130,71 @@ void getEntries(ref HttpRequestContext ctx) { 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(7)); - if (r.isNull(7)) phoneCompliant.isNull = true; - Optional!bool classroomReadiness = absent - ? Optional!bool.empty - : Optional!bool.of(r.getBoolean(8)); - if (r.isNull(8)) classroomReadiness.isNull = true; - Optional!ubyte behaviorRating = absent - ? Optional!ubyte.empty - : Optional!ubyte.of(r.getUbyte(9)); - if (r.isNull(9)) behaviorRating.isNull = true; - import std.string : split; - EntriesTableEntry entryData = EntriesTableEntry( - r.getUlong(1), - r.getDate(2), - r.getUlong(3), - r.getBoolean(4), - r.getString(5), - r.getString(6).split("|||"), - phoneCompliant, - classroomReadiness, - behaviorRating - ); - ClassroomComplianceStudent student = ClassroomComplianceStudent( - r.getUlong(10), - r.getString(11), - r.getUlong(14), - r.getUshort(12), - r.getBoolean(13) - ); - 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.classId, - student.name, - student.deskNumber, - student.removed, - [dateStr: entryData], - Optional!double.empty + 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. @@ -286,45 +254,10 @@ void saveEntries(ref HttpRequestContext ctx) { 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 id, class_id, student_id, date, created_at, - absent, comment, - ( - SELECT STRING_AGG(ck.item, '|||') - FROM classroom_compliance_entry_comment_checklist ck - WHERE ck.entry_id = id - ), - phone_compliant, classroom_readiness, behavior_rating - 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); + // 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); } } } @@ -357,10 +290,9 @@ private void deleteEntry( "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( +private void insertEntry( Connection conn, ulong classId, ulong studentId, @@ -370,25 +302,29 @@ private void insertNewEntry( bool absent = payload.object["absent"].boolean; string comment = payload.object["comment"].str; if (comment is null) comment = ""; - string[] checklistItems; + EntriesTableEntryChecklistItem[] checklistItems; if ("checklistItems" in payload.object) { checklistItems = payload.object["checklistItems"].array - .map!(v => v.str) + .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; } - Optional!bool classroomReadiness = Optional!bool.empty; - Optional!ubyte behaviorRating = Optional!ubyte.empty; - if (!absent) { - classroomReadiness = Optional!bool.of(payload.object["classroomReadiness"].boolean); - behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer); + // If absent, ensure no checklist items may be checked. + if (absent) { + foreach (ref ck; checklistItems) { + ck.checked = false; + } } // Do the main insert first. - import std.variant; - Variant newEntryId; const query = " INSERT INTO classroom_compliance_entry - (class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating) - VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id"; + (class_id, student_id, date, absent, comment) + VALUES (?, ?, ?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(query); scope(exit) ps.close(); ps.setUlong(1, classId); @@ -396,84 +332,23 @@ private void insertNewEntry( ps.setString(3, dateStr); ps.setBoolean(4, absent); ps.setString(5, comment); - if (absent) { - ps.setNull(6); - ps.setNull(7); - } else { - ps.setBoolean(6, classroomReadiness.value); - ps.setUbyte(7, behaviorRating.value); - } - ps.executeUpdate(newEntryId); - updateEntryCommentChecklistItems(conn, newEntryId.coerce!ulong, checklistItems); - - 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 = ""; - string[] checklistItems; - if ("checklistItems" in obj.object) { - checklistItems = obj.object["checklistItems"].array - .map!(v => v.str) - .array; - } - Optional!bool classroomReadiness = Optional!bool.empty; - Optional!ubyte behaviorRating = Optional!ubyte.empty; - if (!absent) { - classroomReadiness = Optional!bool.of(obj.object["classroomReadiness"].boolean); - behaviorRating = Optional!ubyte.of(cast(ubyte) obj.object["behaviorRating"].integer); - } - const query = " - UPDATE classroom_compliance_entry - SET absent = ?, comment = ?, classroom_readiness = ?, 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, classroomReadiness.value); - ps.setUbyte(4, behaviorRating.value); - } - ps.setUlong(5, classId); - ps.setUlong(6, studentId); - ps.setString(7, dateStr); - ps.setUlong(8, entryId); ps.executeUpdate(); - updateEntryCommentChecklistItems(conn, entryId, checklistItems); - - infoF!"Updated entry %d"(entryId); -} - -private void updateEntryCommentChecklistItems(Connection conn, ulong entryId, string[] items) { - PreparedStatement ps1 = conn.prepareStatement( - "DELETE FROM classroom_compliance_entry_comment_checklist WHERE entry_id = ?" - ); - scope(exit) ps1.close(); - ps1.setUlong(1, entryId); - ps1.executeUpdate(); - const checklistItemsQuery = " - INSERT INTO classroom_compliance_entry_comment_checklist (entry_id, item) - VALUES (?, ?) - "; - PreparedStatement ps2 = conn.prepareStatement(checklistItemsQuery); - scope(exit) ps2.close(); - foreach (item; items) { - ps2.setUlong(1, entryId); - ps2.setString(2, item); - ps2.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(); + } } } diff --git a/api/source/api_modules/classroom_compliance/api_export.d b/api/source/api_modules/classroom_compliance/api_export.d index 3a9366a..d0d8f14 100644 --- a/api/source/api_modules/classroom_compliance/api_export.d +++ b/api/source/api_modules/classroom_compliance/api_export.d @@ -28,9 +28,6 @@ void getFullExport(ref HttpRequestContext ctx) { e.date AS entry_date, e.created_at AS entry_created_at, e.absent AS absent, - e.phone_compliant AS phone_compliant, - e.classroom_readiness AS classroom_readiness, - e.behavior_rating AS behavior_rating, e.comment AS comment FROM classroom_compliance_class c LEFT JOIN classroom_compliance_student s @@ -62,9 +59,6 @@ void getFullExport(ref HttpRequestContext ctx) { CSVColumnDef("Entry Date", (r, i) => r.getDate(i).toISOExtString()), CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))), CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"), - CSVColumnDef("Phone Compliant", &formatPhoneCompliance), - CSVColumnDef("Classroom Readiness", &formatClassroomReadiness), - CSVColumnDef("Behavior Rating", &formatBehaviorRating), CSVColumnDef("Comment", &formatComment) ]; // Write headers first. diff --git a/api/source/api_modules/classroom_compliance/api_student.d b/api/source/api_modules/classroom_compliance/api_student.d index 83177f8..3718d9f 100644 --- a/api/source/api_modules/classroom_compliance/api_student.d +++ b/api/source/api_modules/classroom_compliance/api_student.d @@ -157,26 +157,55 @@ void getStudentEntries(ref HttpRequestContext ctx) { scope(exit) conn.close(); User user = getUserOrThrow(ctx, conn); auto student = getStudentOrThrow(ctx, conn, user); - auto entries = findAll( - conn, - "SELECT id, class_id, student_id, date, created_at, - absent, comment, - ( - SELECT STRING_AGG(ck.item, '|||') - FROM classroom_compliance_entry_comment_checklist ck - WHERE ck.entry_id = id - ), - phone_compliant, classroom_readiness, behavior_rating - FROM classroom_compliance_entry - WHERE student_id = ? - GROUP BY classroom_compliance_entry.id - ORDER BY date DESC", - &ClassroomComplianceEntry.parse, - student.id - ); - JSONValue response = JSONValue.emptyArray; - foreach (entry; entries) response.array ~= entry.toJsonObj(); - ctx.response.writeBodyString(response.toJSON(), "application/json"); + + const query = import("source/api_modules/classroom_compliance/queries/find_entries_by_student.sql"); + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + ps.setUlong(1, student.id); + ResultSet rs = ps.executeQuery(); + + ClassroomComplianceEntry entry; + ClassroomComplianceEntryChecklistItem[] checklistItems; + JSONValue responseArray = JSONValue.emptyArray; + + bool hasNextRow = rs.next(); + while (hasNextRow) { + entry.date = rs.getDate(1); + entry.classId = rs.getUlong(2); + entry.createdAt = rs.getUlong(3); + entry.absent = rs.getBoolean(4); + entry.comment = rs.getString(5); + entry.studentId = student.id; + bool hasChecklistItem = !rs.isNull(6); + if (hasChecklistItem) { + checklistItems ~= ClassroomComplianceEntryChecklistItem( + rs.getString(6), + rs.getBoolean(7), + rs.getString(8) + ); + } + + hasNextRow = rs.next(); + bool shouldSaveEntry = !hasNextRow || rs.getDate(1) != entry.date; + if (shouldSaveEntry) { + JSONValue obj = JSONValue.emptyObject; + obj.object["date"] = JSONValue(entry.date.toISOExtString()); + obj.object["createdAt"] = JSONValue(entry.createdAt); + obj.object["absent"] = JSONValue(entry.absent); + obj.object["comment"] = JSONValue(entry.comment); + obj.object["checklistItems"] = JSONValue.emptyArray; + foreach (item; checklistItems) { + JSONValue ckObj = JSONValue.emptyObject; + ckObj.object["item"] = JSONValue(item.item); + ckObj.object["checked"] = JSONValue(item.checked); + ckObj.object["category"] = JSONValue(item.category); + obj.object["checklistItems"].array ~= ckObj; + } + responseArray.array ~= obj; + checklistItems = []; + } + } + ctx.response.writeBodyString(responseArray.toJSON(), "application/json"); } void getStudentOverview(ref HttpRequestContext ctx) { @@ -187,7 +216,9 @@ void getStudentOverview(ref HttpRequestContext ctx) { const ulong entryCount = count( conn, - "SELECT COUNT(id) FROM classroom_compliance_entry WHERE student_id = ?", + "SELECT COUNT(DISTINCT(class_id, date)) + FROM classroom_compliance_entry + WHERE student_id = ?", student.id ); if (entryCount == 0) { @@ -197,37 +228,20 @@ void getStudentOverview(ref HttpRequestContext ctx) { } const ulong absenceCount = count( conn, - "SELECT COUNT(id) FROM classroom_compliance_entry WHERE student_id = ? AND absent = true", + "SELECT COUNT(DISTINCT(class_id, date)) + FROM classroom_compliance_entry + WHERE student_id = ? AND absent = true", student.id ); - const ulong phoneNoncomplianceCount = count( - conn, - "SELECT COUNT(id) FROM classroom_compliance_entry WHERE phone_compliant = FALSE AND student_id = ?", - student.id - ); - const behaviorCountQuery = " - SELECT COUNT(id) - FROM classroom_compliance_entry - WHERE student_id = ? AND behavior_rating = ? - "; - - const ulong behaviorGoodCount = count(conn, behaviorCountQuery, student.id, 3); - const ulong behaviorMediocreCount = count(conn, behaviorCountQuery, student.id, 2); - const ulong behaviorPoorCount = count(conn, behaviorCountQuery, student.id, 1); // Calculate derived statistics. const ulong attendanceCount = entryCount - absenceCount; double attendanceRate = attendanceCount / cast(double) entryCount; - double phoneComplianceRate = (attendanceCount - phoneNoncomplianceCount) / cast(double) attendanceCount; - double behaviorScore = ( - behaviorGoodCount * 1.0 + - behaviorMediocreCount * 0.5 - ) / attendanceCount; JSONValue response = JSONValue.emptyObject; response.object["attendanceRate"] = JSONValue(attendanceRate); - response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate); - response.object["behaviorScore"] = JSONValue(behaviorScore); + // response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate); + // response.object["behaviorScore"] = JSONValue(behaviorScore); response.object["entryCount"] = JSONValue(entryCount); ctx.response.writeBodyString(response.toJSON(), "application/json"); } diff --git a/api/source/api_modules/classroom_compliance/model.d b/api/source/api_modules/classroom_compliance/model.d index 52cfbd5..6f2f8de 100644 --- a/api/source/api_modules/classroom_compliance/model.d +++ b/api/source/api_modules/classroom_compliance/model.d @@ -47,52 +47,19 @@ struct ClassroomComplianceStudent { } struct ClassroomComplianceEntry { - ulong id; ulong classId; ulong studentId; Date date; + ulong createdAt; bool absent; string comment; - string[] checklistItems; - Optional!bool phoneCompliant; - Optional!bool classroomReadiness; - Optional!ubyte behaviorRating; +} - static ClassroomComplianceEntry parse(DataSetReader r) { - ClassroomComplianceEntry entry; - entry.id = r.getUlong(1); - entry.classId = r.getUlong(2); - entry.studentId = r.getUlong(3); - entry.date = r.getDate(4); - entry.createdAt = r.getUlong(5); - entry.absent = r.getBoolean(6); - entry.comment = r.getString(7); - entry.checklistItems = r.getString(8).split("|||"); - entry.phoneCompliant = r.isNull(9) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(9)); - entry.classroomReadiness = r.isNull(10) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(10)); - entry.behaviorRating = r.isNull(11) ? Optional!ubyte.empty : Optional!ubyte.of(r.getUbyte(11)); - return entry; - } - - JSONValue toJsonObj() const { - JSONValue obj = JSONValue.emptyObject; - obj.object["id"] = JSONValue(id); - obj.object["classId"] = JSONValue(classId); - obj.object["studentId"] = JSONValue(studentId); - 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 (item; checklistItems) { - obj.object["checklistItems"].array ~= JSONValue(item); - } - obj.object["phoneCompliant"] = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value); - obj.object["classroomReadiness"] = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value); - obj.object["behaviorRating"] = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value); - return obj; - } +struct ClassroomComplianceEntryChecklistItem { + string item; + bool checked; + string category; } struct ClassroomComplianceClassNote { diff --git a/api/source/api_modules/classroom_compliance/queries/find_entries_by_class.sql b/api/source/api_modules/classroom_compliance/queries/find_entries_by_class.sql new file mode 100644 index 0000000..8d8327a --- /dev/null +++ b/api/source/api_modules/classroom_compliance/queries/find_entries_by_class.sql @@ -0,0 +1,30 @@ +SELECT + student.id, + student.name, + student.desk_number, + student.removed, + + entry.date, + entry.created_at, + entry.absent, + entry.comment, + + ck.item, + ck.checked, + ck.category +FROM classroom_compliance_entry entry +LEFT JOIN classroom_compliance_student student + ON student.id = entry.student_id +LEFT JOIN classroom_compliance_entry_checklist_item ck + ON ck.class_id = entry.class_id AND + ck.student_id = entry.student_id AND + ck.date = entry.date +WHERE + entry.class_id = ? + AND entry.date >= ? + AND entry.date <= ? +ORDER BY + student.id ASC, + entry.date ASC, + ck.category ASC, + ck.item ASC \ No newline at end of file diff --git a/api/source/api_modules/classroom_compliance/queries/find_entries_by_student.sql b/api/source/api_modules/classroom_compliance/queries/find_entries_by_student.sql new file mode 100644 index 0000000..adf24a8 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/queries/find_entries_by_student.sql @@ -0,0 +1,21 @@ +SELECT + entry.date, + entry.class_id, + entry.created_at, + entry.absent, + entry.comment, + + ck.item, + ck.checked, + ck.category +FROM classroom_compliance_entry entry +LEFT JOIN classroom_compliance_entry_checklist_item ck + ON ck.class_id = entry.class_id AND + ck.student_id = entry.student_id AND + ck.date = entry.date +WHERE + entry.student_id = ? +ORDER BY + entry.date DESC, + ck.category ASC, + ck.item ASC \ No newline at end of file diff --git a/api/source/api_modules/classroom_compliance/queries/find_scoring_parameters.sql b/api/source/api_modules/classroom_compliance/queries/find_scoring_parameters.sql new file mode 100644 index 0000000..89dbb6e --- /dev/null +++ b/api/source/api_modules/classroom_compliance/queries/find_scoring_parameters.sql @@ -0,0 +1,31 @@ +SELECT + entry.student_id, + COUNT(DISTINCT(entry.student_id, entry.date)) AS entry_count, + SUM( + CASE WHEN ck.category = 'Classroom Readiness' + THEN 1 ELSE 0 END + ) + AS classroom_readiness_item_count, + SUM( + CASE WHEN ck.category = 'Classroom Readiness' AND ck.checked + THEN 1 ELSE 0 END + ) + AS classroom_readiness_item_count_checked, + SUM( + CASE WHEN ck.category = 'Behavior' + THEN 1 ELSE 0 END + ) AS behavior_item_count, + SUM( + CASE WHEN ck.category = 'Behavior' AND ck.checked + THEN 1 ELSE 0 END + ) AS behavior_item_count_checked +FROM classroom_compliance_entry entry +LEFT JOIN classroom_compliance_entry_checklist_item ck + ON ck.class_id = entry.class_id AND + ck.student_id = entry.student_id AND + ck.date = entry.date +WHERE + entry.date >= ? + AND entry.date <= ? + AND entry.class_id = ? +GROUP BY entry.student_id \ No newline at end of file diff --git a/api/source/api_modules/classroom_compliance/score.d b/api/source/api_modules/classroom_compliance/score.d index a9b2397..b2bd362 100644 --- a/api/source/api_modules/classroom_compliance/score.d +++ b/api/source/api_modules/classroom_compliance/score.d @@ -18,6 +18,16 @@ private struct ScoringParameters { DateRange dateRange; } +private struct ChecklistItemCategoryStats { + ulong total; + ulong checked; + + double getUncheckedRatio() const { + double unchecked = total - checked; + return unchecked / total; + } +} + /** * Gets an associative array that maps student ids to their (optional) scores. * Scores are calculated based on aggregate statistics from their entries. A @@ -48,23 +58,7 @@ Optional!double[ulong] getScores( params.dateRange.to ); - 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 classroom_readiness = FALSE THEN 1 ELSE 0 END) AS not_classroom_ready_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 - "; + const query = import("source/api_modules/classroom_compliance/queries/find_scoring_parameters.sql"); PreparedStatement ps = conn.prepareStatement(query); scope(exit) ps.close(); ps.setDate(1, params.dateRange.from); @@ -72,22 +66,20 @@ Optional!double[ulong] getScores( 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 notClassroomReadyCount = r.getUint(5); - uint behaviorGoodCount = r.getUint(6); - uint behaviorMediocreCount = r.getUint(7); - uint behaviorPoorCount = r.getUint(8); + ulong entryCount = r.getUlong(2); + ChecklistItemCategoryStats[string] categoryStats; + categoryStats["classroom_readiness"] = ChecklistItemCategoryStats( + r.getUlong(3), + r.getUlong(4) + ); + categoryStats["behavior"] = ChecklistItemCategoryStats( + r.getUlong(5), + r.getUlong(6) + ); scores[studentId] = calculateScore( params.expr, entryCount, - absenceCount, - phoneNonComplianceCount, - notClassroomReadyCount, - behaviorGoodCount, - behaviorMediocreCount, - behaviorPoorCount + categoryStats ); } return scores; @@ -132,49 +124,32 @@ private DateRange getDateRangeFromPeriodAndDate(in Date date, in string period) */ private Optional!double calculateScore( in Expr scoreExpression, - uint entryCount, - uint absenceCount, - uint phoneNonComplianceCount, - uint notClassroomReadyCount, - uint behaviorGoodCount, - uint behaviorMediocreCount, - uint behaviorPoorCount + ulong entryCount, + in ChecklistItemCategoryStats[string] categoryStats ) { - if ( - entryCount == 0 - || entryCount <= absenceCount - ) return Optional!double.empty; + if (entryCount == 0) return Optional!double.empty; - const uint presentCount = entryCount - absenceCount; - - // Phone subscore: - uint phoneCompliantCount; - if (presentCount < phoneNonComplianceCount) { - phoneCompliantCount = 0; - } else { - phoneCompliantCount = presentCount - phoneNonComplianceCount; + double classroomReadinessScore = 0; + if ("classroom_readiness" in categoryStats) { + classroomReadinessScore = categoryStats["classroom_readiness"].getUncheckedRatio(); } - double phoneScore = phoneCompliantCount / cast(double) presentCount; - // Classroom readiness score: - uint classroomReadyCount; - if (presentCount < notClassroomReadyCount) { - classroomReadyCount = 0; - } else { - classroomReadyCount = presentCount - notClassroomReadyCount; + + double behaviorScore = 0; + if ("behavior" in categoryStats) { + behaviorScore = categoryStats["behavior"].getUncheckedRatio(); } - double classroomReadinessScore = classroomReadyCount / cast(double) presentCount; - double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount; - double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount; - double behaviorPoorScore = behaviorPoorCount / cast(double) presentCount; - double typicalBehaviorScore = (1.0 * behaviorGoodScore + 0.5 * behaviorMediocreScore); - - return Optional!double.of(scoreExpression.eval([ - "phone": phoneScore, - "classroom_readiness": classroomReadinessScore, - "behavior": typicalBehaviorScore, - "behavior_good": behaviorGoodScore, - "behavior_mediocre": behaviorMediocreScore, - "behavior_poor": behaviorPoorScore - ])); + try { + double score = scoreExpression.eval([ + "classroom_readiness": classroomReadinessScore, + "behavior": behaviorScore, + ]); + import std.math : isNaN; + if (isNaN(score)) return Optional!double.empty; + return Optional!double.of(score); + } catch (ExpressionEvaluationException e) { + import handy_httpd; + throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to compute score: " ~ e.msg); + } + } diff --git a/api/source/sample_data.d b/api/source/sample_data.d index a9f0a89..0604905 100644 --- a/api/source/sample_data.d +++ b/api/source/sample_data.d @@ -80,18 +80,7 @@ void addClassroomComplianceSampleData(ref Random rand, ulong adminUserId, Connec bool missingEntry = uniform01(rand) < 0.05; if (missingEntry) continue; - bool absent = uniform01(rand) < 0.05; - bool phoneCompliant = uniform01(rand) < 0.85; - ubyte behaviorRating = 3; - if (uniform01(rand) < 0.25) { - behaviorRating = 2; - if (uniform01(rand) < 0.5) { - behaviorRating = 1; - } - } - bool hasComment = uniform01(rand) < 0.2; - string comment = hasComment ? "Test comment." : ""; - addEntry(conn, classId, studentId, entryDate, absent, phoneCompliant, behaviorRating, comment); + addEntry(conn, classId, studentId, entryDate, rand); } } } @@ -130,35 +119,49 @@ void addEntry( ulong classId, ulong studentId, Date date, - bool absent, - bool phoneCompliant, - ubyte behaviorRating, - string comment + ref Random rand ) { + bool absent = uniform01(rand) < 0.05; + bool hasComment = uniform01(rand) < 0.25; + string comment = hasComment ? "This is a sample comment." : ""; + const entryQuery = " INSERT INTO classroom_compliance_entry - (class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + (class_id, student_id, date, absent, comment) + VALUES (?, ?, ?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(entryQuery); scope(exit) ps.close(); ps.setUlong(1, classId); ps.setUlong(2, studentId); ps.setDate(3, date); ps.setBoolean(4, absent); - if (comment is null) { - ps.setString(5, ""); - } else { - ps.setString(5, comment); - } - ps.setNull(6); // Always set phone_compliant as null from now on. - if (absent) { - ps.setNull(7); - ps.setNull(8); - } else { - ps.setBoolean(7, true); - ps.setUint(8, behaviorRating); - } + ps.setString(5, comment); ps.executeUpdate(); + + bool hasChecklistItems = uniform01(rand) < 0.75; + if (!hasChecklistItems) return; + + const checklistItemQuery = " + INSERT INTO classroom_compliance_entry_checklist_item + (class_id, student_id, date, item, checked, category) + VALUES (?, ?, ?, ?, ?, ?)"; + PreparedStatement ckPs = conn.prepareStatement(checklistItemQuery); + scope(exit) ckPs.close(); + const categories = ["Classroom Readiness", "Behavior"]; + foreach (category; categories) { + for (int i = 0; i < 6; i++) { + import std.conv : to; + string item = "Checklist item " ~ (i+1).to!string; + bool checked = uniform01(rand) < 0.25; + ckPs.setUlong(1, classId); + ckPs.setUlong(2, studentId); + ckPs.setDate(3, date); + ckPs.setString(4, item); + ckPs.setBoolean(5, checked); + ckPs.setString(6, category); + ckPs.executeUpdate(); + } + } } void deleteAllData(Connection conn) { @@ -168,7 +171,7 @@ void deleteAllData(Connection conn) { const tables = [ "announcement", "classroom_compliance_class_note", - "classroom_compliance_entry_comment_checklist", + "classroom_compliance_entry_checklist_item", "classroom_compliance_entry", "classroom_compliance_student", "classroom_compliance_class", diff --git a/app/src/api/classroom_compliance.ts b/app/src/api/classroom_compliance.ts index 50f194d..ffd63e5 100644 --- a/app/src/api/classroom_compliance.ts +++ b/app/src/api/classroom_compliance.ts @@ -2,8 +2,6 @@ import { APIClient, APIResponse, type AuthStoreType } from './base' export const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance' -export const EMOJI_PHONE_COMPLIANT = '📱' -export const EMOJI_PHONE_NONCOMPLIANT = '📵' export const EMOJI_CLASSROOM_READY = '🍎' export const EMOJI_NOT_CLASSROOM_READY = '🐛' export const EMOJI_PRESENT = '✅' @@ -12,6 +10,30 @@ export const EMOJI_BEHAVIOR_GOOD = '😇' export const EMOJI_BEHAVIOR_MEDIOCRE = '😐' export const EMOJI_BEHAVIOR_POOR = '😡' +export enum ComplianceLevel { + Good, + Mediocre, + Poor, +} + +export const DEFAULT_CHECKLIST_ITEMS: Record = { + 'Classroom Readiness': [ + 'Tardy', + 'Out of Uniform', + 'Use of wireless tech', + '10+ minute bathroom pass', + 'Not having laptop', + 'Not in assigned seat', + ], + Behavior: [ + 'Talking out of turn', + 'Throwing objects in class', + 'Rude language / comments to peers', + 'Disrespectful towards the teacher', + 'Disrupting / distracting peers or teacher', + ], +} + export interface Class { id: number number: number @@ -40,31 +62,55 @@ export interface Student { } export interface Entry { - id: number date: string createdAt: number absent: boolean - phoneCompliant: boolean | null - classroomReadiness: boolean | null - behaviorRating: number | null comment: string - checklistItems: string[] + checklistItems: EntryChecklistItem[] +} + +export interface EntryChecklistItem { + item: string + checked: boolean + category: string } export function getDefaultEntry(dateStr: string): Entry { + const defaultChecklistItems: EntryChecklistItem[] = [] + for (const category in DEFAULT_CHECKLIST_ITEMS) { + for (const item of DEFAULT_CHECKLIST_ITEMS[category]) { + defaultChecklistItems.push({ item, category, checked: false }) + } + } + defaultChecklistItems.sort((a, b) => { + const cmpCat = a.category.localeCompare(b.category) + if (cmpCat !== 0) return cmpCat + return a.item.localeCompare(b.category) + }) return { - id: 0, date: dateStr, createdAt: Date.now(), absent: false, - phoneCompliant: null, - classroomReadiness: true, - behaviorRating: 3, comment: '', - checklistItems: [], + checklistItems: defaultChecklistItems, } } +export function getCategoryScore(entry: Entry, category: string): number | null { + const matchingItems = entry.checklistItems.filter((ck) => ck.category === category) + if (matchingItems.length === 0) return null + const unchecked = matchingItems.filter((ck) => !ck.checked).length + return unchecked / matchingItems.length +} + +export function getComplianceLevel(entry: Entry, category: string): ComplianceLevel | null { + const score = getCategoryScore(entry, category) + if (score === null) return null + if (score < 0.5) return ComplianceLevel.Poor + if (score < 1) return ComplianceLevel.Mediocre + return ComplianceLevel.Good +} + export interface EntriesResponseStudent { id: number classId: number @@ -106,8 +152,6 @@ export interface ScoresResponse { export interface StudentStatisticsOverview { attendanceRate: number - phoneComplianceRate: number - behaviorScore: number entryCount: number } diff --git a/app/src/apps/classroom_compliance/ClassView.vue b/app/src/apps/classroom_compliance/ClassView.vue index 00c080e..cc7dbb8 100644 --- a/app/src/apps/classroom_compliance/ClassView.vue +++ b/app/src/apps/classroom_compliance/ClassView.vue @@ -136,7 +136,7 @@ function resetScoreParameters() { -
+

Notes

-
-
-

-