diff --git a/api/schema/classroom_compliance.sql b/api/schema/classroom_compliance.sql index f9fee75..cb7c1f8 100644 --- a/api/schema/classroom_compliance.sql +++ b/api/schema/classroom_compliance.sql @@ -6,8 +6,9 @@ CREATE TABLE classroom_compliance_class ( user_id BIGINT NOT NULL REFERENCES auth_user(id) ON UPDATE CASCADE ON DELETE CASCADE, - score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * phone + 0.5 * behavior', + score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * classroom_readiness + 0.5 * behavior', score_period VARCHAR(64) NOT NULL DEFAULT 'week', + archived BOOLEAN NOT NULL DEFAULT FALSE, CONSTRAINT unique_class_numbers_per_school_year UNIQUE(number, school_year, user_id) ); @@ -36,15 +37,24 @@ CREATE TABLE classroom_compliance_entry ( 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 phone_compliant IS NULL AND behavior_rating IS NULL) OR - (NOT absent AND phone_compliant IS NOT NULL AND behavior_rating IS NOT NULL) + (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) ); +CREATE TABLE classroom_compliance_entry_comment_checklist ( + entry_id BIGINT NOT NULL + REFERENCES classroom_compliance_entry(id) + ON UPDATE CASCADE ON DELETE CASCADE, + item VARCHAR(2000) NOT NULL, + PRIMARY KEY (entry_id, item) +); + CREATE TABLE classroom_compliance_class_note ( id BIGSERIAL PRIMARY KEY, class_id BIGINT NOT NULL diff --git a/api/source/api_modules/announcement.d b/api/source/api_modules/announcement.d index 432e1bb..c11aa83 100644 --- a/api/source/api_modules/announcement.d +++ b/api/source/api_modules/announcement.d @@ -10,9 +10,9 @@ import data_utils; import api_modules.auth : getAdminUserOrThrow; struct Announcement { - const ulong id; - const string type; - const string message; + ulong id; + string type; + string message; static Announcement parse(DataSetReader r) { return Announcement( diff --git a/api/source/api_modules/auth.d b/api/source/api_modules/auth.d index f93ea65..9913374 100644 --- a/api/source/api_modules/auth.d +++ b/api/source/api_modules/auth.d @@ -13,12 +13,12 @@ import db; import data_utils; struct User { - const ulong id; - const string username; - const string passwordHash; - const ulong createdAt; - const bool isLocked; - const bool isAdmin; + ulong id; + string username; + string passwordHash; + ulong createdAt; + bool isLocked; + bool isAdmin; static User parse(DataSetReader r) { return User( diff --git a/api/source/api_modules/classroom_compliance/api.d b/api/source/api_modules/classroom_compliance/api.d index 864e9ca..3bd8779 100644 --- a/api/source/api_modules/classroom_compliance/api.d +++ b/api/source/api_modules/classroom_compliance/api.d @@ -16,6 +16,7 @@ void registerApiEndpoints(PathHandler handler) { 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 ~ "/archived", &setArchived); handler.addMapping(Method.GET, CLASS_PATH ~ "/notes", &getClassNotes); handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote); handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote); diff --git a/api/source/api_modules/classroom_compliance/api_class.d b/api/source/api_modules/classroom_compliance/api_class.d index b67a9a6..32ef21c 100644 --- a/api/source/api_modules/classroom_compliance/api_class.d +++ b/api/source/api_modules/classroom_compliance/api_class.d @@ -48,7 +48,7 @@ void getClasses(ref HttpRequestContext ctx) { User user = getUserOrThrow(ctx, conn); const query = " SELECT - c.id, c.number, c.school_year, + c.id, c.number, c.school_year, c.archived, COUNT(DISTINCT s.id) AS student_count, COUNT(DISTINCT e.id) AS entry_count, MAX(e.date) AS last_entry_date @@ -63,6 +63,7 @@ void getClasses(ref HttpRequestContext ctx) { ulong id; ushort number; string schoolYear; + bool archived; uint studentCount; uint entryCount; string lastEntryDate; @@ -74,9 +75,10 @@ void getClasses(ref HttpRequestContext ctx) { r.getUlong(1), r.getUshort(2), r.getString(3), - r.getUint(4), + r.getBoolean(4), r.getUint(5), - r.getDate(6).toISOExtString() + r.getUint(6), + r.getDate(7).toISOExtString() ), user.id ); @@ -96,6 +98,7 @@ void deleteClass(ref HttpRequestContext ctx) { 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."); const query = "DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?"; PreparedStatement ps = conn.prepareStatement(query); scope(exit) ps.close(); @@ -119,6 +122,7 @@ void createClassNote(ref HttpRequestContext ctx) { 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."); struct Payload {string content;} Payload payload = readJsonPayload!(Payload)(ctx); const query = "INSERT INTO classroom_compliance_class_note (class_id, content) VALUES (?, ?) RETURNING id"; @@ -137,6 +141,7 @@ void deleteClassNote(ref HttpRequestContext ctx) { 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."); ulong noteId = ctx.request.getPathParamAs!ulong("noteId"); const query = "DELETE FROM classroom_compliance_class_note WHERE class_id = ? AND id = ?"; update(conn, query, cls.id, noteId); @@ -147,10 +152,20 @@ void resetStudentDesks(ref HttpRequestContext ctx) { 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."); const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?"; update(conn, query, cls.id); } +void setArchived(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + const query = "UPDATE classroom_compliance_class SET archived = ? WHERE id = ?"; + update(conn, query, !cls.archived, cls.id); +} + void updateScoreParameters(ref HttpRequestContext ctx) { import api_modules.classroom_compliance.score_eval : validateExpression; import std.algorithm : canFind; @@ -158,6 +173,7 @@ void updateScoreParameters(ref HttpRequestContext ctx) { 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."); struct Payload { string scoreExpression; string scorePeriod; diff --git a/api/source/api_modules/classroom_compliance/api_entry.d b/api/source/api_modules/classroom_compliance/api_entry.d index 66b90fb..30a0e57 100644 --- a/api/source/api_modules/classroom_compliance/api_entry.d +++ b/api/source/api_modules/classroom_compliance/api_entry.d @@ -22,7 +22,9 @@ struct EntriesTableEntry { ulong createdAt; bool absent; string comment; + string[] checklistItems; Optional!bool phoneCompliant; + Optional!bool classroomReadiness; Optional!ubyte behaviorRating; JSONValue toJsonObj() const { @@ -32,12 +34,21 @@ struct EntriesTableEntry { 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 { - obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value); - obj.object["behaviorRating"] = JSONValue(behaviorRating.value); + 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; } return obj; } @@ -125,7 +136,14 @@ void getEntries(ref HttpRequestContext ctx) { 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, @@ -155,25 +173,34 @@ void getEntries(ref HttpRequestContext ctx) { const absent = r.getBoolean(4); Optional!bool phoneCompliant = absent ? Optional!bool.empty - : Optional!bool.of(r.getBoolean(6)); + : 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(7)); + : 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(8), - r.getString(9), - r.getUlong(12), - r.getUshort(10), - r.getBoolean(11) + r.getUlong(10), + r.getString(11), + r.getUlong(14), + r.getUshort(12), + r.getBoolean(13) ); string dateStr = entryData.date.toISOExtString(); @@ -252,6 +279,7 @@ void saveEntries(ref HttpRequestContext ctx) { 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) { @@ -265,7 +293,16 @@ void saveEntries(ref HttpRequestContext ctx) { Optional!ClassroomComplianceEntry existingEntry = findOne( conn, - "SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", + "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 ); @@ -333,17 +370,25 @@ private void insertNewEntry( bool absent = payload.object["absent"].boolean; string comment = payload.object["comment"].str; if (comment is null) comment = ""; - Optional!bool phoneCompliant = Optional!bool.empty; + string[] checklistItems; + if ("checklistItems" in payload.object) { + checklistItems = payload.object["checklistItems"].array + .map!(v => v.str) + .array; + } + Optional!bool classroomReadiness = Optional!bool.empty; Optional!ubyte behaviorRating = Optional!ubyte.empty; if (!absent) { - phoneCompliant = Optional!bool.of(payload.object["phoneCompliant"].boolean); + classroomReadiness = Optional!bool.of(payload.object["classroomReadiness"].boolean); behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer); } + // Do the main insert first. + import std.variant; + Variant newEntryId; const query = " INSERT INTO classroom_compliance_entry - (class_id, student_id, date, absent, comment, phone_compliant, behavior_rating) - VALUES (?, ?, ?, ?, ?, ?, ?)"; - + (class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id"; PreparedStatement ps = conn.prepareStatement(query); scope(exit) ps.close(); ps.setUlong(1, classId); @@ -355,10 +400,11 @@ private void insertNewEntry( ps.setNull(6); ps.setNull(7); } else { - ps.setBoolean(6, phoneCompliant.value); + ps.setBoolean(6, classroomReadiness.value); ps.setUbyte(7, behaviorRating.value); } - ps.executeUpdate(); + ps.executeUpdate(newEntryId); + updateEntryCommentChecklistItems(conn, newEntryId.coerce!ulong, checklistItems); infoF!"Created new entry for student %d: %s"(studentId, payload); } @@ -374,15 +420,21 @@ private void updateEntry( bool absent = obj.object["absent"].boolean; string comment = obj.object["comment"].str; if (comment is null) comment = ""; - Optional!bool phoneCompliant = Optional!bool.empty; + 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) { - phoneCompliant = Optional!bool.of(obj.object["phoneCompliant"].boolean); + 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 = ?, phone_compliant = ?, behavior_rating = ? + SET absent = ?, comment = ?, classroom_readiness = ?, behavior_rating = ? WHERE class_id = ? AND student_id = ? AND date = ? AND id = ? "; PreparedStatement ps = conn.prepareStatement(query); @@ -393,7 +445,7 @@ private void updateEntry( ps.setNull(3); ps.setNull(4); } else { - ps.setBoolean(3, phoneCompliant.value); + ps.setBoolean(3, classroomReadiness.value); ps.setUbyte(4, behaviorRating.value); } ps.setUlong(5, classId); @@ -401,6 +453,27 @@ private void updateEntry( 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(); + } +} diff --git a/api/source/api_modules/classroom_compliance/api_export.d b/api/source/api_modules/classroom_compliance/api_export.d index 53da3f2..3a9366a 100644 --- a/api/source/api_modules/classroom_compliance/api_export.d +++ b/api/source/api_modules/classroom_compliance/api_export.d @@ -29,6 +29,7 @@ void getFullExport(ref HttpRequestContext ctx) { 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 @@ -62,6 +63,7 @@ void getFullExport(ref HttpRequestContext ctx) { 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) ]; @@ -95,6 +97,11 @@ private string formatPhoneCompliance(DataSetReader r, int i) { return r.getBoolean(i) ? "Compliant" : "Non-compliant"; } +private string formatClassroomReadiness(DataSetReader r, int i) { + if (r.isNull(i)) return "N/A"; + return r.getBoolean(i) ? "Ready" : "Not Ready"; +} + private string formatBehaviorRating(DataSetReader r, int i) { if (r.isNull(i)) return "N/A"; ubyte score = r.getUbyte(i); diff --git a/api/source/api_modules/classroom_compliance/api_student.d b/api/source/api_modules/classroom_compliance/api_student.d index 336b6ad..6cfed85 100644 --- a/api/source/api_modules/classroom_compliance/api_student.d +++ b/api/source/api_modules/classroom_compliance/api_student.d @@ -15,6 +15,7 @@ void createStudent(ref HttpRequestContext ctx) { 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."); struct StudentPayload { string name; ushort deskNumber; @@ -83,6 +84,8 @@ void updateStudent(ref HttpRequestContext ctx) { Connection conn = getDb(); 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."); auto student = getStudentOrThrow(ctx, conn, user); struct StudentUpdatePayload { string name; @@ -139,6 +142,8 @@ void deleteStudent(ref HttpRequestContext ctx) { Connection conn = getDb(); 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."); auto student = getStudentOrThrow(ctx, conn, user); update( conn, @@ -154,7 +159,18 @@ void getStudentEntries(ref HttpRequestContext ctx) { auto student = getStudentOrThrow(ctx, conn, user); auto entries = findAll( conn, - "SELECT * FROM classroom_compliance_entry WHERE student_id = ? ORDER BY date DESC", + "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 ); @@ -221,6 +237,8 @@ void moveStudentToOtherClass(ref HttpRequestContext ctx) { scope(exit) conn.close(); conn.setAutoCommit(false); User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived."); auto student = getStudentOrThrow(ctx, conn, user); struct Payload { ulong classId; diff --git a/api/source/api_modules/classroom_compliance/model.d b/api/source/api_modules/classroom_compliance/model.d index 40c2169..52cfbd5 100644 --- a/api/source/api_modules/classroom_compliance/model.d +++ b/api/source/api_modules/classroom_compliance/model.d @@ -4,20 +4,23 @@ import ddbc : DataSetReader; import handy_httpd.components.optional; import std.json; import std.datetime; +import std.string : split; struct ClassroomComplianceClass { - const ulong id; - const ushort number; - const string schoolYear; - const ulong userId; - const string scoreExpression; - const string scorePeriod; + ulong id; + ushort number; + string schoolYear; + bool archived; + ulong userId; + string scoreExpression; + string scorePeriod; static ClassroomComplianceClass parse(DataSetReader r) { return ClassroomComplianceClass( r.getUlong(1), r.getUshort(2), r.getString(3), + r.getBoolean(7), r.getUlong(4), r.getString(5), r.getString(6) @@ -26,11 +29,11 @@ struct ClassroomComplianceClass { } struct ClassroomComplianceStudent { - const ulong id; - const string name; - const ulong classId; - const ushort deskNumber; - const bool removed; + ulong id; + string name; + ulong classId; + ushort deskNumber; + bool removed; static ClassroomComplianceStudent parse(DataSetReader r) { return ClassroomComplianceStudent( @@ -44,35 +47,32 @@ struct ClassroomComplianceStudent { } struct ClassroomComplianceEntry { - const ulong id; - const ulong classId; - const ulong studentId; - const Date date; - const ulong createdAt; - const bool absent; - const string comment; - const Optional!bool phoneCompliant; - const Optional!ubyte behaviorRating; + 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) { - Optional!bool phone = !r.isNull(8) - ? Optional!bool.of(r.getBoolean(8)) - : Optional!bool.empty; - Optional!ubyte behavior = !r.isNull(9) - ? Optional!ubyte.of(r.getUbyte(9)) - : Optional!ubyte.empty; - - return ClassroomComplianceEntry( - r.getUlong(1), - r.getUlong(2), - r.getUlong(3), - r.getDate(4), - r.getUlong(5), - r.getBoolean(6), - r.getString(7), - phone, - behavior - ); + 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 { @@ -84,28 +84,22 @@ struct ClassroomComplianceEntry { obj.object["createdAt"] = JSONValue(createdAt); obj.object["absent"] = JSONValue(absent); obj.object["comment"] = JSONValue(comment); - if (absent) { - if (!phoneCompliant.isNull || !behaviorRating.isNull) { - throw new Exception("Illegal entry state! Absent is true while values are not null!"); - } - obj.object["phoneCompliant"] = JSONValue(null); - obj.object["behaviorRating"] = JSONValue(null); - } else { - if (phoneCompliant.isNull || behaviorRating.isNull) { - throw new Exception("Illegal entry state! Absent is false while values are null!"); - } - obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value); - obj.object["behaviorRating"] = JSONValue(behaviorRating.value); + 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 ClassroomComplianceClassNote { - const ulong id; - const ulong classId; - const ulong createdAt; - const string content; + ulong id; + ulong classId; + ulong createdAt; + string content; static ClassroomComplianceClassNote parse(DataSetReader r) { return ClassroomComplianceClassNote( diff --git a/api/source/api_modules/classroom_compliance/score.d b/api/source/api_modules/classroom_compliance/score.d index 896f158..a9b2397 100644 --- a/api/source/api_modules/classroom_compliance/score.d +++ b/api/source/api_modules/classroom_compliance/score.d @@ -15,7 +15,7 @@ enum ScorePeriod : string { private struct ScoringParameters { Expr expr; - const DateRange dateRange; + DateRange dateRange; } /** @@ -54,6 +54,7 @@ Optional!double[ulong] getScores( 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 @@ -74,14 +75,16 @@ Optional!double[ulong] getScores( uint entryCount = r.getUint(2); uint absenceCount = r.getUint(3); uint phoneNonComplianceCount = r.getUint(4); - uint behaviorGoodCount = r.getUint(5); - uint behaviorMediocreCount = r.getUint(6); - uint behaviorPoorCount = r.getUint(7); + uint notClassroomReadyCount = r.getUint(5); + uint behaviorGoodCount = r.getUint(6); + uint behaviorMediocreCount = r.getUint(7); + uint behaviorPoorCount = r.getUint(8); scores[studentId] = calculateScore( params.expr, entryCount, absenceCount, phoneNonComplianceCount, + notClassroomReadyCount, behaviorGoodCount, behaviorMediocreCount, behaviorPoorCount @@ -132,6 +135,7 @@ private Optional!double calculateScore( uint entryCount, uint absenceCount, uint phoneNonComplianceCount, + uint notClassroomReadyCount, uint behaviorGoodCount, uint behaviorMediocreCount, uint behaviorPoorCount @@ -151,6 +155,14 @@ private Optional!double calculateScore( phoneCompliantCount = presentCount - phoneNonComplianceCount; } double phoneScore = phoneCompliantCount / cast(double) presentCount; + // Classroom readiness score: + uint classroomReadyCount; + if (presentCount < notClassroomReadyCount) { + classroomReadyCount = 0; + } else { + classroomReadyCount = presentCount - notClassroomReadyCount; + } + double classroomReadinessScore = classroomReadyCount / cast(double) presentCount; double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount; double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount; @@ -159,6 +171,7 @@ private Optional!double calculateScore( return Optional!double.of(scoreExpression.eval([ "phone": phoneScore, + "classroom_readiness": classroomReadinessScore, "behavior": typicalBehaviorScore, "behavior_good": behaviorGoodScore, "behavior_mediocre": behaviorMediocreScore, diff --git a/api/source/db.d b/api/source/db.d index 123cef5..c37b64a 100644 --- a/api/source/db.d +++ b/api/source/db.d @@ -35,7 +35,8 @@ void initializeSchema() { scope(exit) stmt.close(); const string AUTH_SCHEMA = import("schema/auth.sql"); const string CLASSROOM_COMPLIANCE_SCHEMA = import("schema/classroom_compliance.sql"); - const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA]; + const string ANNOUNCEMENT_SCHEMA = import("schema/announcement.sql"); + const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA, ANNOUNCEMENT_SCHEMA]; uint schemaNumber = 1; foreach (schema; schemas) { infoF!"Intializing schema #%d."(schemaNumber++); diff --git a/api/source/sample_data.d b/api/source/sample_data.d index 7cde680..a9f0a89 100644 --- a/api/source/sample_data.d +++ b/api/source/sample_data.d @@ -137,8 +137,8 @@ void addEntry( ) { const entryQuery = " INSERT INTO classroom_compliance_entry - (class_id, student_id, date, absent, comment, phone_compliant, behavior_rating) - VALUES (?, ?, ?, ?, ?, ?, ?)"; + (class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; PreparedStatement ps = conn.prepareStatement(entryQuery); scope(exit) ps.close(); ps.setUlong(1, classId); @@ -150,12 +150,13 @@ void addEntry( } else { ps.setString(5, comment); } + ps.setNull(6); // Always set phone_compliant as null from now on. if (absent) { - ps.setNull(6); ps.setNull(7); + ps.setNull(8); } else { - ps.setBoolean(6, phoneCompliant); - ps.setUint(7, behaviorRating); + ps.setBoolean(7, true); + ps.setUint(8, behaviorRating); } ps.executeUpdate(); } @@ -167,6 +168,7 @@ void deleteAllData(Connection conn) { const tables = [ "announcement", "classroom_compliance_class_note", + "classroom_compliance_entry_comment_checklist", "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 940aab7..49b33f3 100644 --- a/app/src/api/classroom_compliance.ts +++ b/app/src/api/classroom_compliance.ts @@ -4,6 +4,8 @@ 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 = '✅' export const EMOJI_ABSENT = '❌' export const EMOJI_BEHAVIOR_GOOD = '😇' @@ -16,6 +18,7 @@ export interface Class { schoolYear: string scoreExpression: string scorePeriod: string + archived: boolean } export interface ClassesResponseClass { @@ -25,6 +28,7 @@ export interface ClassesResponseClass { studentCount: number entryCount: number lastEntryDate: string + archived: boolean } export interface Student { @@ -41,8 +45,10 @@ export interface Entry { createdAt: number absent: boolean phoneCompliant: boolean | null + classroomReadiness: boolean | null behaviorRating: number | null comment: string + checklistItems: string[] } export function getDefaultEntry(dateStr: string): Entry { @@ -51,9 +57,11 @@ export function getDefaultEntry(dateStr: string): Entry { date: dateStr, createdAt: Date.now(), absent: false, - phoneCompliant: true, + phoneCompliant: null, + classroomReadiness: true, behaviorRating: 3, comment: '', + checklistItems: [], } } @@ -152,6 +160,10 @@ export class ClassroomComplianceAPIClient extends APIClient { return new APIResponse(this.handleAPIResponseWithNoBody(promise)) } + toggleArchived(classId: number): APIResponse { + return super.postWithNoExpectedResponse(`/classes/${classId}/archived`, {}) + } + updateScoreParameters( classId: number, scoreExpression: string, diff --git a/app/src/apps/classroom_compliance/ClassItem.vue b/app/src/apps/classroom_compliance/ClassItem.vue index 9b5df80..c24b5ab 100644 --- a/app/src/apps/classroom_compliance/ClassItem.vue +++ b/app/src/apps/classroom_compliance/ClassItem.vue @@ -9,7 +9,9 @@ const router = useRouter() diff --git a/app/src/apps/classroom_compliance/ClassView.vue b/app/src/apps/classroom_compliance/ClassView.vue index b707b14..aa0bf38 100644 --- a/app/src/apps/classroom_compliance/ClassView.vue +++ b/app/src/apps/classroom_compliance/ClassView.vue @@ -17,6 +17,7 @@ const notes: Ref = ref([]) const noteContent: Ref = ref('') const scoreExpression: Ref = ref('') const scorePeriod: Ref = ref('') +const archived = computed(() => cls.value !== null && cls.value.archived) const canUpdateScoreParameters = computed(() => { return cls.value && ( cls.value.scoreExpression !== scoreExpression.value || @@ -53,6 +54,12 @@ async function deleteThisClass() { await router.replace('/classroom-compliance') } +async function toggleArchived() { + if (!cls.value) return + await apiClient.toggleArchived(cls.value.id).handleErrorsWithAlertNoBody() + loadClass() +} + async function submitNote() { if (noteContent.value.trim().length < 1 || !cls.value) return const note = await apiClient.createClassNote(cls.value?.id, noteContent.value).handleErrorsWithAlert() @@ -113,22 +120,28 @@ function resetScoreParameters() {

Class

For the {{ cls.schoolYear }} school year.

- - - - + + +
- +

Notes

- - + +
@@ -142,7 +155,7 @@ function resetScoreParameters() {
+ maxlength="255" style="min-width: 500px; min-height: 50px;" :readonly="archived">

The expression to use to calculate each student's score. This should be a simple mathematical expression that @@ -169,7 +182,7 @@ function resetScoreParameters() {

-
@@ -177,8 +190,9 @@ function resetScoreParameters() { The period over which to calculate scores.

- - + +
diff --git a/app/src/apps/classroom_compliance/ClassesView.vue b/app/src/apps/classroom_compliance/ClassesView.vue index 7e97c88..7183db2 100644 --- a/app/src/apps/classroom_compliance/ClassesView.vue +++ b/app/src/apps/classroom_compliance/ClassesView.vue @@ -1,11 +1,13 @@ diff --git a/app/src/apps/classroom_compliance/entries_table/DateHeaderCell.vue b/app/src/apps/classroom_compliance/entries_table/DateHeaderCell.vue index 91b6047..b2b6bdf 100644 --- a/app/src/apps/classroom_compliance/entries_table/DateHeaderCell.vue +++ b/app/src/apps/classroom_compliance/entries_table/DateHeaderCell.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; const props = defineProps<{ dateStr: string + disabled?: boolean }>() defineEmits<{ @@ -27,7 +28,7 @@ function getWeekday(date: Date): string {