From 6177943db78adb8b215d9c69061af79b043cbb56 Mon Sep 17 00:00:00 2001
From: andrewlalis
Date: Mon, 1 Sep 2025 16:22:29 -0400
Subject: [PATCH] Added changes for new school year.
---
api/schema/classroom_compliance.sql | 16 ++-
api/source/api_modules/announcement.d | 6 +-
api/source/api_modules/auth.d | 12 +-
.../api_modules/classroom_compliance/api.d | 1 +
.../classroom_compliance/api_class.d | 22 +++-
.../classroom_compliance/api_entry.d | 115 ++++++++++++++----
.../classroom_compliance/api_export.d | 7 ++
.../classroom_compliance/api_student.d | 20 ++-
.../api_modules/classroom_compliance/model.d | 102 ++++++++--------
.../api_modules/classroom_compliance/score.d | 21 +++-
api/source/db.d | 3 +-
api/source/sample_data.d | 12 +-
app/src/api/classroom_compliance.ts | 14 ++-
.../apps/classroom_compliance/ClassItem.vue | 4 +-
.../classroom_compliance/ClassNoteItem.vue | 3 +-
.../apps/classroom_compliance/ClassView.vue | 38 ++++--
.../apps/classroom_compliance/ClassesView.vue | 10 +-
.../classroom_compliance/EntriesTable.vue | 16 ++-
.../classroom_compliance/StudentEntryItem.vue | 10 +-
.../apps/classroom_compliance/StudentView.vue | 104 +++++++++++++++-
.../entries_table/DateHeaderCell.vue | 3 +-
.../entries_table/EntryTableCell.vue | 75 ++++++++++--
app/src/components/AnnouncementsBanner.vue | 2 +-
23 files changed, 476 insertions(+), 140 deletions(-)
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()
-
Class
+
Class
+
{{ cls.studentCount }} students,
{{ cls.entryCount }} entries.
diff --git a/app/src/apps/classroom_compliance/ClassNoteItem.vue b/app/src/apps/classroom_compliance/ClassNoteItem.vue
index c9465dd..ea2d3cb 100644
--- a/app/src/apps/classroom_compliance/ClassNoteItem.vue
+++ b/app/src/apps/classroom_compliance/ClassNoteItem.vue
@@ -4,6 +4,7 @@ import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
note: ClassNote
+ disabled?: boolean
}>()
const emit = defineEmits(['noteDeleted'])
const authStore = useAuthStore()
@@ -21,7 +22,7 @@ async function deleteNote() {
{{ note.content }}
{{ new Date(note.createdAt).toLocaleString() }}
-
Delete
+
Delete
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.
- Add
+ Add
Student
- Import
+ Import
Students
- Clear Assigned Desks
- Delete this Class
+ Clear Assigned Desks
+ Delete this Class
+
+ Unarchive
+ Archive
+
-
+
Notes
@@ -142,7 +155,7 @@ function resetScoreParameters() {
Expression
+ 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() {
Period
-
+
Weekly
@@ -177,8 +190,9 @@ function resetScoreParameters() {
The period over which to calculate scores.
- Save
- Reset
+ Save
+ Reset
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 @@
@@ -73,6 +108,7 @@ async function deleteThisStudent() {
Edit
Delete
+ Week overview
@@ -116,6 +152,49 @@ async function deleteThisStudent() {
This cannot be undone!
+
+
+
+
This week's overview for
+
+
{{ getFormattedDate(entry) }}
+
+
+
Classroom Readiness
+
✅ Ready for class
+
❌ Not ready for class
+
+
+
+
Behavior
+
+ Poor
+
+
+ Mediocre
+
+
+ Good
+
+
+
+
+
Instructor Remarks
+
{{ entry.comment }}
+
+
+
+
Classroom Readiness & Behavioral Issues
+
+
+
+
+
+ Close
+
+
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 {