Added lots more improvements, including scores.
This commit is contained in:
parent
3a682e046d
commit
3f47be9653
|
@ -35,6 +35,5 @@ CREATE TABLE classroom_compliance_entry_phone (
|
||||||
CREATE TABLE classroom_compliance_entry_behavior (
|
CREATE TABLE classroom_compliance_entry_behavior (
|
||||||
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
|
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
rating INTEGER NOT NULL,
|
rating INTEGER NOT NULL
|
||||||
comment TEXT
|
|
||||||
);
|
);
|
||||||
|
|
|
@ -25,7 +25,7 @@ private struct UserResponse {
|
||||||
bool isAdmin;
|
bool isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional!User getUser(ref HttpRequestContext ctx) {
|
Optional!User getUser(ref HttpRequestContext ctx, ref Database db) {
|
||||||
import std.base64;
|
import std.base64;
|
||||||
import std.string : startsWith;
|
import std.string : startsWith;
|
||||||
import std.digest.sha;
|
import std.digest.sha;
|
||||||
|
@ -40,7 +40,6 @@ Optional!User getUser(ref HttpRequestContext ctx) {
|
||||||
size_t idx = countUntil(decoded, ':');
|
size_t idx = countUntil(decoded, ':');
|
||||||
string username = decoded[0..idx];
|
string username = decoded[0..idx];
|
||||||
auto passwordHash = toHexString(sha256Of(decoded[idx+1 .. $]));
|
auto passwordHash = toHexString(sha256Of(decoded[idx+1 .. $]));
|
||||||
Database db = getDb();
|
|
||||||
Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username);
|
Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username);
|
||||||
if (!optUser.isNull && optUser.value.passwordHash != passwordHash) {
|
if (!optUser.isNull && optUser.value.passwordHash != passwordHash) {
|
||||||
return Optional!User.empty;
|
return Optional!User.empty;
|
||||||
|
@ -48,8 +47,8 @@ Optional!User getUser(ref HttpRequestContext ctx) {
|
||||||
return optUser;
|
return optUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
User getUserOrThrow(ref HttpRequestContext ctx) {
|
User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
|
||||||
Optional!User optUser = getUser(ctx);
|
Optional!User optUser = getUser(ctx, db);
|
||||||
if (optUser.isNull) {
|
if (optUser.isNull) {
|
||||||
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
||||||
}
|
}
|
||||||
|
@ -57,7 +56,8 @@ User getUserOrThrow(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void loginEndpoint(ref HttpRequestContext ctx) {
|
void loginEndpoint(ref HttpRequestContext ctx) {
|
||||||
Optional!User optUser = getUser(ctx);
|
Database db = getDb();
|
||||||
|
Optional!User optUser = getUser(ctx, db);
|
||||||
if (optUser.isNull) {
|
if (optUser.isNull) {
|
||||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||||
ctx.response.writeBodyString("Invalid credentials.");
|
ctx.response.writeBodyString("Invalid credentials.");
|
||||||
|
|
|
@ -6,7 +6,6 @@ import d2sqlite3;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import std.typecons : Nullable;
|
import std.typecons : Nullable;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
import std.format;
|
|
||||||
import std.json;
|
import std.json;
|
||||||
import std.algorithm;
|
import std.algorithm;
|
||||||
import std.array;
|
import std.array;
|
||||||
|
@ -47,7 +46,6 @@ struct ClassroomComplianceEntryPhone {
|
||||||
struct ClassroomComplianceEntryBehavior {
|
struct ClassroomComplianceEntryBehavior {
|
||||||
const ulong entryId;
|
const ulong entryId;
|
||||||
const ubyte rating;
|
const ubyte rating;
|
||||||
const string comment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void registerApiEndpoints(PathHandler handler) {
|
void registerApiEndpoints(PathHandler handler) {
|
||||||
|
@ -71,13 +69,13 @@ void registerApiEndpoints(PathHandler handler) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void createClass(ref HttpRequestContext ctx) {
|
void createClass(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
auto db = getDb();
|
||||||
|
User user = getUserOrThrow(ctx, db);
|
||||||
struct ClassPayload {
|
struct ClassPayload {
|
||||||
ushort number;
|
ushort number;
|
||||||
string schoolYear;
|
string schoolYear;
|
||||||
}
|
}
|
||||||
auto payload = readJsonPayload!(ClassPayload)(ctx);
|
auto payload = readJsonPayload!(ClassPayload)(ctx);
|
||||||
auto db = getDb();
|
|
||||||
const bool classNumberExists = canFind(
|
const bool classNumberExists = canFind(
|
||||||
db,
|
db,
|
||||||
"SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?",
|
"SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?",
|
||||||
|
@ -104,8 +102,8 @@ void createClass(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void getClasses(ref HttpRequestContext ctx) {
|
void getClasses(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
|
User user = getUserOrThrow(ctx, db);
|
||||||
auto classes = findAll!(ClassroomComplianceClass)(
|
auto classes = findAll!(ClassroomComplianceClass)(
|
||||||
db,
|
db,
|
||||||
"SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC",
|
"SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC",
|
||||||
|
@ -115,21 +113,21 @@ void getClasses(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void getClass(ref HttpRequestContext ctx) {
|
void getClass(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
auto db = getDb();
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto cls = getClassOrThrow(ctx, db, user);
|
||||||
writeJsonBody(ctx, cls);
|
writeJsonBody(ctx, cls);
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteClass(ref HttpRequestContext ctx) {
|
void deleteClass(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto cls = getClassOrThrow(ctx, db, user);
|
||||||
db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id);
|
db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, in User user) {
|
ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) {
|
||||||
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
||||||
auto db = getDb();
|
|
||||||
return findOne!(ClassroomComplianceClass)(
|
return findOne!(ClassroomComplianceClass)(
|
||||||
db,
|
db,
|
||||||
"SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?",
|
"SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?",
|
||||||
|
@ -139,15 +137,15 @@ ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, in User use
|
||||||
}
|
}
|
||||||
|
|
||||||
void createStudent(ref HttpRequestContext ctx) {
|
void createStudent(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
auto db = getDb();
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto cls = getClassOrThrow(ctx, db, user);
|
||||||
struct StudentPayload {
|
struct StudentPayload {
|
||||||
string name;
|
string name;
|
||||||
ushort deskNumber;
|
ushort deskNumber;
|
||||||
bool removed;
|
bool removed;
|
||||||
}
|
}
|
||||||
auto payload = readJsonPayload!(StudentPayload)(ctx);
|
auto payload = readJsonPayload!(StudentPayload)(ctx);
|
||||||
auto db = getDb();
|
|
||||||
bool studentExists = canFind(
|
bool studentExists = canFind(
|
||||||
db,
|
db,
|
||||||
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
||||||
|
@ -183,9 +181,9 @@ void createStudent(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void getStudents(ref HttpRequestContext ctx) {
|
void getStudents(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto cls = getClassOrThrow(ctx, db, user);
|
||||||
auto students = findAll!(ClassroomComplianceStudent)(
|
auto students = findAll!(ClassroomComplianceStudent)(
|
||||||
db,
|
db,
|
||||||
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
||||||
|
@ -195,14 +193,16 @@ void getStudents(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void getStudent(ref HttpRequestContext ctx) {
|
void getStudent(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
auto db = getDb();
|
||||||
auto student = getStudentOrThrow(ctx, user);
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto student = getStudentOrThrow(ctx, db, user);
|
||||||
writeJsonBody(ctx, student);
|
writeJsonBody(ctx, student);
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateStudent(ref HttpRequestContext ctx) {
|
void updateStudent(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
auto db = getDb();
|
||||||
auto student = getStudentOrThrow(ctx, user);
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto student = getStudentOrThrow(ctx, db, user);
|
||||||
struct StudentUpdatePayload {
|
struct StudentUpdatePayload {
|
||||||
string name;
|
string name;
|
||||||
ushort deskNumber;
|
ushort deskNumber;
|
||||||
|
@ -216,7 +216,6 @@ void updateStudent(ref HttpRequestContext ctx) {
|
||||||
&& payload.removed == student.removed
|
&& payload.removed == student.removed
|
||||||
) return;
|
) return;
|
||||||
// Check that the new name doesn't already exist.
|
// Check that the new name doesn't already exist.
|
||||||
auto db = getDb();
|
|
||||||
bool newNameExists = payload.name != student.name && canFind(
|
bool newNameExists = payload.name != student.name && canFind(
|
||||||
db,
|
db,
|
||||||
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
||||||
|
@ -256,9 +255,9 @@ void updateStudent(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteStudent(ref HttpRequestContext ctx) {
|
void deleteStudent(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
|
||||||
auto student = getStudentOrThrow(ctx, user);
|
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto student = getStudentOrThrow(ctx, db, user);
|
||||||
db.execute(
|
db.execute(
|
||||||
"DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?",
|
"DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?",
|
||||||
student.id,
|
student.id,
|
||||||
|
@ -266,10 +265,9 @@ void deleteStudent(ref HttpRequestContext ctx) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User user) {
|
ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) {
|
||||||
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
||||||
ulong studentId = ctx.request.getPathParamAs!ulong("studentId");
|
ulong studentId = ctx.request.getPathParamAs!ulong("studentId");
|
||||||
auto db = getDb();
|
|
||||||
string query = "
|
string query = "
|
||||||
SELECT s.*
|
SELECT s.*
|
||||||
FROM classroom_compliance_student s
|
FROM classroom_compliance_student s
|
||||||
|
@ -286,8 +284,9 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User
|
||||||
}
|
}
|
||||||
|
|
||||||
void getEntries(ref HttpRequestContext ctx) {
|
void getEntries(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
auto db = getDb();
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto cls = getClassOrThrow(ctx, db, user);
|
||||||
// Default to getting entries from the last 5 days.
|
// Default to getting entries from the last 5 days.
|
||||||
SysTime now = Clock.currTime();
|
SysTime now = Clock.currTime();
|
||||||
Date toDate = Date(now.year, now.month, now.day);
|
Date toDate = Date(now.year, now.month, now.day);
|
||||||
|
@ -310,10 +309,19 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (fromDate > toDate) {
|
||||||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
|
ctx.response.writeBodyString("Invalid date range. From-date must be less than or equal to the to-date.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (toDate - fromDate > days(10)) {
|
||||||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
|
ctx.response.writeBodyString("Date range is too big. Only ranges of 10 days or less are allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
|
infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
|
||||||
|
|
||||||
auto db = getDb();
|
// First prepare a list of all students, including ones which don't have any entries.
|
||||||
|
|
||||||
ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)(
|
ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)(
|
||||||
db,
|
db,
|
||||||
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
||||||
|
@ -326,10 +334,11 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
obj.object["name"] = JSONValue(s.name);
|
obj.object["name"] = JSONValue(s.name);
|
||||||
obj.object["removed"] = JSONValue(s.removed);
|
obj.object["removed"] = JSONValue(s.removed);
|
||||||
obj.object["entries"] = JSONValue.emptyObject;
|
obj.object["entries"] = JSONValue.emptyObject;
|
||||||
|
obj.object["score"] = JSONValue(null);
|
||||||
return obj;
|
return obj;
|
||||||
}).array;
|
}).array;
|
||||||
|
|
||||||
const query = "
|
const entriesQuery = "
|
||||||
SELECT
|
SELECT
|
||||||
entry.id,
|
entry.id,
|
||||||
entry.date,
|
entry.date,
|
||||||
|
@ -340,8 +349,7 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
student.desk_number,
|
student.desk_number,
|
||||||
student.removed,
|
student.removed,
|
||||||
phone.compliant,
|
phone.compliant,
|
||||||
behavior.rating,
|
behavior.rating
|
||||||
behavior.comment
|
|
||||||
FROM classroom_compliance_entry entry
|
FROM classroom_compliance_entry entry
|
||||||
LEFT JOIN classroom_compliance_entry_phone phone
|
LEFT JOIN classroom_compliance_entry_phone phone
|
||||||
ON phone.entry_id = entry.id
|
ON phone.entry_id = entry.id
|
||||||
|
@ -357,9 +365,9 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
student.id ASC,
|
student.id ASC,
|
||||||
entry.date ASC
|
entry.date ASC
|
||||||
";
|
";
|
||||||
ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
|
ResultRange entriesResult = db.execute(entriesQuery, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
|
||||||
// Serialize the results into a custom-formatted response object.
|
// Serialize the results into a custom-formatted response object.
|
||||||
foreach (row; result) {
|
foreach (row; entriesResult) {
|
||||||
JSONValue entry = JSONValue.emptyObject;
|
JSONValue entry = JSONValue.emptyObject;
|
||||||
entry.object["id"] = JSONValue(row.peek!ulong(0));
|
entry.object["id"] = JSONValue(row.peek!ulong(0));
|
||||||
entry.object["date"] = JSONValue(row.peek!string(1));
|
entry.object["date"] = JSONValue(row.peek!string(1));
|
||||||
|
@ -373,7 +381,6 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
phone.object["compliant"] = JSONValue(row.peek!bool(8));
|
phone.object["compliant"] = JSONValue(row.peek!bool(8));
|
||||||
behavior = JSONValue.emptyObject;
|
behavior = JSONValue.emptyObject;
|
||||||
behavior.object["rating"] = JSONValue(row.peek!ubyte(9));
|
behavior.object["rating"] = JSONValue(row.peek!ubyte(9));
|
||||||
behavior.object["comment"] = JSONValue(row.peek!string(10));
|
|
||||||
}
|
}
|
||||||
entry.object["phone"] = phone;
|
entry.object["phone"] = phone;
|
||||||
entry.object["behavior"] = behavior;
|
entry.object["behavior"] = behavior;
|
||||||
|
@ -394,6 +401,23 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find scores for each student for this timeframe.
|
||||||
|
Optional!double[ulong] scores = getScores(db, cls.id, fromDate, toDate);
|
||||||
|
foreach (studentId, score; scores) {
|
||||||
|
JSONValue scoreValue = score.isNull ? JSONValue(null) : JSONValue(score.value);
|
||||||
|
bool studentFound = false;
|
||||||
|
foreach (idx, student; students) {
|
||||||
|
if (studentId == student.id) {
|
||||||
|
studentObjects[idx].object["score"] = scoreValue;
|
||||||
|
studentFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!studentFound) {
|
||||||
|
throw new Exception("Failed to find student.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
JSONValue response = JSONValue.emptyObject;
|
JSONValue response = JSONValue.emptyObject;
|
||||||
// Provide the list of dates that we're providing data for, to make it easier for the frontend.
|
// Provide the list of dates that we're providing data for, to make it easier for the frontend.
|
||||||
response.object["dates"] = JSONValue.emptyArray;
|
response.object["dates"] = JSONValue.emptyArray;
|
||||||
|
@ -417,63 +441,79 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void saveEntries(ref HttpRequestContext ctx) {
|
void saveEntries(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
|
||||||
JSONValue bodyContent = ctx.request.readBodyAsJson();
|
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
|
User user = getUserOrThrow(ctx, db);
|
||||||
|
auto cls = getClassOrThrow(ctx, db, user);
|
||||||
|
JSONValue bodyContent = ctx.request.readBodyAsJson();
|
||||||
db.begin();
|
db.begin();
|
||||||
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
|
try {
|
||||||
ulong studentId = studentObj.object["id"].integer();
|
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
|
||||||
JSONValue entries = studentObj.object["entries"];
|
ulong studentId = studentObj.object["id"].integer();
|
||||||
foreach (string dateStr, JSONValue entry; entries.object) {
|
JSONValue entries = studentObj.object["entries"];
|
||||||
if (entry.isNull) {
|
foreach (string dateStr, JSONValue entry; entries.object) {
|
||||||
db.execute(
|
if (entry.isNull) {
|
||||||
"DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
deleteEntry(db, cls.id, studentId, dateStr);
|
||||||
cls.id,
|
continue;
|
||||||
studentId,
|
}
|
||||||
dateStr
|
|
||||||
|
Optional!ClassroomComplianceEntry existingEntry = findOne!(ClassroomComplianceEntry)(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
||||||
|
cls.id, studentId, dateStr
|
||||||
);
|
);
|
||||||
infoF!"Deleted entry for student %s on %s"(studentId, dateStr);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional!ClassroomComplianceEntry existingEntry = findOne!(ClassroomComplianceEntry)(
|
ulong entryId = entry.object["id"].integer();
|
||||||
db,
|
bool creatingNewEntry = entryId == 0;
|
||||||
"SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
|
||||||
cls.id, studentId, dateStr
|
|
||||||
);
|
|
||||||
|
|
||||||
ulong entryId = entry.object["id"].integer();
|
if (creatingNewEntry) {
|
||||||
bool creatingNewEntry = entryId == 0;
|
if (!existingEntry.isNull) {
|
||||||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
|
ctx.response.writeBodyString("Cannot create a new entry when one already exists.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (creatingNewEntry) {
|
insertNewEntry(db, cls.id, studentId, dateStr, entry);
|
||||||
if (!existingEntry.isNull) {
|
} else {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
if (existingEntry.isNull) {
|
||||||
ctx.response.writeBodyString(
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
format!"Cannot create a new entry for student %d on %s when one already exists."(
|
ctx.response.writeBodyString("Cannot update an entry which doesn't exist.");
|
||||||
studentId,
|
return;
|
||||||
dateStr
|
}
|
||||||
)
|
updateEntry(db, cls.id, studentId, dateStr, entryId, entry);
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
insertNewEntry(db, cls.id, studentId, dateStr, entry);
|
|
||||||
} else {
|
|
||||||
if (existingEntry.isNull) {
|
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
||||||
ctx.response.writeBodyString(
|
|
||||||
format!"Cannot update entry %d because it doesn't exist."(
|
|
||||||
entryId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateEntry(db, cls.id, studentId, dateStr, entryId, entry);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
db.commit();
|
||||||
|
} catch (HttpStatusException e) {
|
||||||
|
db.rollback();
|
||||||
|
ctx.response.status = e.status;
|
||||||
|
ctx.response.writeBodyString(e.message);
|
||||||
|
} catch (JSONException e) {
|
||||||
|
db.rollback();
|
||||||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
|
ctx.response.writeBodyString("Invalid JSON payload.");
|
||||||
|
warn(e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
db.rollback();
|
||||||
|
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg);
|
||||||
|
error(e);
|
||||||
}
|
}
|
||||||
db.commit();
|
}
|
||||||
|
|
||||||
|
private void deleteEntry(
|
||||||
|
ref Database db,
|
||||||
|
ulong classId,
|
||||||
|
ulong studentId,
|
||||||
|
string dateStr
|
||||||
|
) {
|
||||||
|
db.execute(
|
||||||
|
"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 insertNewEntry(
|
||||||
|
@ -507,9 +547,9 @@ private void insertNewEntry(
|
||||||
);
|
);
|
||||||
ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer;
|
ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer;
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment)
|
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating)
|
||||||
VALUES (?, ?, ?)",
|
VALUES (?, ?)",
|
||||||
entryId, behaviorRating, ""
|
entryId, behaviorRating
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
infoF!"Created new entry for student %d: %s"(studentId, payload);
|
infoF!"Created new entry for student %d: %s"(studentId, payload);
|
||||||
|
@ -587,3 +627,102 @@ private void updateEntry(
|
||||||
}
|
}
|
||||||
infoF!"Updated entry %d"(entryId);
|
infoF!"Updated entry %d"(entryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Optional!double[ulong] getScores(
|
||||||
|
ref Database db,
|
||||||
|
ulong classId,
|
||||||
|
Date fromDate,
|
||||||
|
Date toDate
|
||||||
|
) {
|
||||||
|
infoF!"Getting scores from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
|
||||||
|
|
||||||
|
// First populate all students with an initial "null" score.
|
||||||
|
Optional!double[ulong] scores;
|
||||||
|
ResultRange studentsResult = db.execute(
|
||||||
|
"SELECT id FROM classroom_compliance_student WHERE class_id = ?",
|
||||||
|
classId
|
||||||
|
);
|
||||||
|
foreach (row; studentsResult) {
|
||||||
|
scores[row.peek!ulong(0)] = Optional!double.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = "
|
||||||
|
SELECT
|
||||||
|
e.student_id,
|
||||||
|
COUNT(e.id) AS entry_count,
|
||||||
|
SUM(e.absent) AS absence_count,
|
||||||
|
SUM(NOT p.compliant) AS phone_noncompliance_count,
|
||||||
|
SUM(b.rating = 3) AS behavior_good,
|
||||||
|
SUM(b.rating = 2) AS behavior_mediocre,
|
||||||
|
SUM(b.rating = 1) AS behavior_poor
|
||||||
|
FROM classroom_compliance_entry e
|
||||||
|
LEFT JOIN classroom_compliance_entry_phone p
|
||||||
|
ON p.entry_id = e.id
|
||||||
|
LEFT JOIN classroom_compliance_entry_behavior b
|
||||||
|
ON b.entry_id = e.id
|
||||||
|
WHERE
|
||||||
|
e.date >= ?
|
||||||
|
AND e.date <= ?
|
||||||
|
AND e.class_id = ?
|
||||||
|
GROUP BY e.student_id
|
||||||
|
";
|
||||||
|
ResultRange result = db.execute(
|
||||||
|
query,
|
||||||
|
fromDate.toISOExtString(),
|
||||||
|
toDate.toISOExtString(),
|
||||||
|
classId
|
||||||
|
);
|
||||||
|
foreach (row; result) {
|
||||||
|
ulong studentId = row.peek!ulong(0);
|
||||||
|
uint entryCount = row.peek!uint(1);
|
||||||
|
uint absenceCount = row.peek!uint(2);
|
||||||
|
uint phoneNonComplianceCount = row.peek!uint(3);
|
||||||
|
uint behaviorGoodCount = row.peek!uint(4);
|
||||||
|
uint behaviorMediocreCount = row.peek!uint(5);
|
||||||
|
uint behaviorPoorCount = row.peek!uint(6);
|
||||||
|
scores[studentId] = calculateScore(
|
||||||
|
entryCount,
|
||||||
|
absenceCount,
|
||||||
|
phoneNonComplianceCount,
|
||||||
|
behaviorGoodCount,
|
||||||
|
behaviorMediocreCount,
|
||||||
|
behaviorPoorCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional!double calculateScore(
|
||||||
|
uint entryCount,
|
||||||
|
uint absenceCount,
|
||||||
|
uint phoneNonComplianceCount,
|
||||||
|
uint behaviorGoodCount,
|
||||||
|
uint behaviorMediocreCount,
|
||||||
|
uint behaviorPoorCount
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
entryCount == 0
|
||||||
|
|| entryCount <= absenceCount
|
||||||
|
) return Optional!double.empty;
|
||||||
|
|
||||||
|
const uint presentCount = entryCount - absenceCount;
|
||||||
|
|
||||||
|
// Phone subscore:
|
||||||
|
uint phoneCompliantCount;
|
||||||
|
if (presentCount < phoneNonComplianceCount) {
|
||||||
|
phoneCompliantCount = 0;
|
||||||
|
} else {
|
||||||
|
phoneCompliantCount = presentCount - phoneNonComplianceCount;
|
||||||
|
}
|
||||||
|
double phoneScore = phoneCompliantCount / cast(double) presentCount;
|
||||||
|
|
||||||
|
// Behavior subscore:
|
||||||
|
double behaviorScore = (
|
||||||
|
behaviorGoodCount * 1.0
|
||||||
|
+ behaviorMediocreCount * 0.5
|
||||||
|
+ behaviorPoorCount * 0
|
||||||
|
) / cast(double) presentCount;
|
||||||
|
|
||||||
|
double score = 0.3 * phoneScore + 0.7 * behaviorScore;
|
||||||
|
return Optional!double.of(score);
|
||||||
|
}
|
||||||
|
|
|
@ -15,19 +15,19 @@ async function logOut() {
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<nav>
|
<nav class="global-navbar">
|
||||||
<RouterLink to="/">Home</RouterLink>
|
<div>
|
||||||
<RouterLink to="/login" v-if="!authStore.state">Login</RouterLink>
|
<RouterLink to="/">Home</RouterLink>
|
||||||
|
<RouterLink to="/my-account" v-if="authStore.state">My Account</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span v-if="authStore.state">
|
<div>
|
||||||
Welcome, <span v-text="authStore.state.username"></span>
|
<RouterLink to="/login" v-if="!authStore.state">Login</RouterLink>
|
||||||
</span>
|
<span v-if="authStore.state" style="margin-right: 0.5em">
|
||||||
<button type="button" @click="logOut" v-if="authStore.state">Log out</button>
|
Logged in as <span v-text="authStore.state.username" style="font-weight: bold;"></span>
|
||||||
</nav>
|
</span>
|
||||||
<nav v-if="authStore.state">
|
<button type="button" @click="logOut" v-if="authStore.state">Log out</button>
|
||||||
Apps:
|
</div>
|
||||||
|
|
||||||
<RouterLink to="/classroom-compliance">Classroom Compliance</RouterLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -38,4 +38,17 @@ async function logOut() {
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.global-navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-navbar>div {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-navbar>div>a+a {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -5,7 +5,7 @@ const BASE_URL = import.meta.env.VITE_API_URL + '/auth'
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
createdAt: Date
|
createdAt: number
|
||||||
isLocked: boolean
|
isLocked: boolean
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,18 +102,12 @@ export abstract class APIClient {
|
||||||
protected async handleAPIResponse<T>(promise: Promise<Response>): Promise<T | APIError> {
|
protected async handleAPIResponse<T>(promise: Promise<Response>): Promise<T | APIError> {
|
||||||
try {
|
try {
|
||||||
const response = await promise
|
const response = await promise
|
||||||
if (response.ok) {
|
if (response.ok) return (await response.json()) as T
|
||||||
return (await response.json()) as T
|
return this.transformErrorResponse(response)
|
||||||
}
|
|
||||||
if (response.status === 400) {
|
|
||||||
return new BadRequestError(await response.text())
|
|
||||||
}
|
|
||||||
if (response.status === 401) {
|
|
||||||
return new AuthenticationError(await response.text())
|
|
||||||
}
|
|
||||||
return new InternalServerError(await response.text())
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new NetworkError('' + error)
|
return new NetworkError(
|
||||||
|
'' + error + " (We couldn't connect to the remote server; it might be down!)",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,12 +117,17 @@ export abstract class APIClient {
|
||||||
try {
|
try {
|
||||||
const response = await promise
|
const response = await promise
|
||||||
if (response.ok) return undefined
|
if (response.ok) return undefined
|
||||||
if (response.status === 401) {
|
return this.transformErrorResponse(response)
|
||||||
return new AuthenticationError(await response.text())
|
|
||||||
}
|
|
||||||
return new InternalServerError(await response.text())
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new NetworkError('' + error)
|
return new NetworkError(
|
||||||
|
'' + error + " (We couldn't connect to the remote server; it might be down!)",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async transformErrorResponse(r: Response): Promise<APIError> {
|
||||||
|
if (r.status === 401) return new AuthenticationError(await r.text())
|
||||||
|
if (r.status === 400) return new BadRequestError(await r.text())
|
||||||
|
return new InternalServerError(await r.text())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,6 @@ export interface EntryPhone {
|
||||||
|
|
||||||
export interface EntryBehavior {
|
export interface EntryBehavior {
|
||||||
rating: number
|
rating: number
|
||||||
comment?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Entry {
|
export interface Entry {
|
||||||
|
@ -51,6 +50,7 @@ export interface EntriesResponseStudent {
|
||||||
deskNumber: number
|
deskNumber: number
|
||||||
removed: boolean
|
removed: boolean
|
||||||
entries: Record<string, Entry | null>
|
entries: Record<string, Entry | null>
|
||||||
|
score: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntriesResponse {
|
export interface EntriesResponse {
|
||||||
|
@ -73,6 +73,15 @@ export interface EntriesPayload {
|
||||||
students: EntriesPayloadStudent[]
|
students: EntriesPayloadStudent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StudentScore {
|
||||||
|
id: number
|
||||||
|
score: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoresResponse {
|
||||||
|
scores: StudentScore[]
|
||||||
|
}
|
||||||
|
|
||||||
export class ClassroomComplianceAPIClient extends APIClient {
|
export class ClassroomComplianceAPIClient extends APIClient {
|
||||||
constructor(authStore: AuthStoreType) {
|
constructor(authStore: AuthStoreType) {
|
||||||
super(BASE_URL, authStore)
|
super(BASE_URL, authStore)
|
||||||
|
@ -132,4 +141,11 @@ export class ClassroomComplianceAPIClient extends APIClient {
|
||||||
saveEntries(classId: number, payload: EntriesPayload): APIResponse<void> {
|
saveEntries(classId: number, payload: EntriesPayload): APIResponse<void> {
|
||||||
return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload)
|
return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getScores(classId: number, fromDate: Date, toDate: Date): APIResponse<ScoresResponse> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('from', fromDate.toISOString().substring(0, 10))
|
||||||
|
params.append('to', toDate.toISOString().substring(0, 10))
|
||||||
|
return super.get(`/classes/${classId}/scores?${params.toString()}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,36 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type Class } from '@/api/classroom_compliance'
|
import { type Class } from '@/api/classroom_compliance'
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
cls: Class
|
cls: Class
|
||||||
}>()
|
}>()
|
||||||
|
const router = useRouter()
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="class-item">
|
<div class="class-item" @click="router.push(`/classroom-compliance/classes/${cls.id}`)">
|
||||||
<h3>Class <span v-text="cls.number"></span></h3>
|
<h3>Class <span v-text="cls.number"></span></h3>
|
||||||
<p v-text="cls.schoolYear"></p>
|
<p v-text="cls.schoolYear"></p>
|
||||||
<div>
|
|
||||||
<RouterLink :to="'/classroom-compliance/classes/' + cls.id">View</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.class-item {
|
.class-item {
|
||||||
border: 1px solid black;
|
border: 2px solid;
|
||||||
|
border-radius: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
cursor: pointer;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-item:hover {
|
||||||
|
border: 2px dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-item+.class-item {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-item>h3 {
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||||
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
|
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
|
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
|
||||||
|
|
||||||
|
@ -35,18 +35,15 @@ async function deleteThisClass() {
|
||||||
<h1>Class #<span v-text="cls.number"></span></h1>
|
<h1>Class #<span v-text="cls.number"></span></h1>
|
||||||
<p>ID: <span v-text="cls.id"></span></p>
|
<p>ID: <span v-text="cls.id"></span></p>
|
||||||
<p>School Year: <span v-text="cls.schoolYear"></span></p>
|
<p>School Year: <span v-text="cls.schoolYear"></span></p>
|
||||||
<div>
|
<div class="button-bar" style="margin-bottom: 1em;">
|
||||||
<div>
|
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)">Add
|
||||||
<span>Actions: </span>
|
Student</button>
|
||||||
<RouterLink :to="'/classroom-compliance/classes/' + cls.id + '/edit-student'"
|
<button type="button" @click="deleteThisClass">Delete this Class</button>
|
||||||
>Add Student</RouterLink
|
|
||||||
>
|
|
||||||
<button type="button" @click="deleteThisClass">Delete this Class</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EntriesTable :classId="cls.id" />
|
<EntriesTable :classId="cls.id" />
|
||||||
|
|
||||||
|
<!-- Confirmation dialog used for attempts at deleting this class. -->
|
||||||
<ConfirmDialog ref="deleteClassDialog">
|
<ConfirmDialog ref="deleteClassDialog">
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to delete this class? All data associated with it (settings, students,
|
Are you sure you want to delete this class? All data associated with it (settings, students,
|
||||||
|
|
|
@ -3,10 +3,12 @@ import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compli
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { type Ref, ref, onMounted } from 'vue'
|
import { type Ref, ref, onMounted } from 'vue'
|
||||||
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
|
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const classes: Ref<Class[]> = ref([])
|
const classes: Ref<Class[]> = ref([])
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
@ -15,9 +17,11 @@ onMounted(async () => {
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div class="button-bar">
|
||||||
<RouterLink to="/classroom-compliance/edit-class">Add Class</RouterLink>
|
<button type="button" @click="router.push('/classroom-compliance/edit-class')">Add Class</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
||||||
</div>
|
</div>
|
||||||
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -69,7 +69,7 @@ function resetForm() {
|
||||||
<label for="school-year-input">School Year (example: "2024-2025")</label>
|
<label for="school-year-input">School Year (example: "2024-2025")</label>
|
||||||
<input id="school-year-input" type="text" v-model="formData.schoolYear" required />
|
<input id="school-year-input" type="text" v-model="formData.schoolYear" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="button-bar">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button type="reset">Cancel</button>
|
<button type="reset">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -93,7 +93,7 @@ function resetForm() {
|
||||||
<span v-if="!student">Add New Student</span>
|
<span v-if="!student">Add New Student</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p>From class <span v-text="cls.number + ', ' + cls.schoolYear"></span></p>
|
<p>In class <span v-text="cls.number + ', ' + cls.schoolYear"></span></p>
|
||||||
|
|
||||||
<form @submit.prevent="submitForm" @reset.prevent="resetForm">
|
<form @submit.prevent="submitForm" @reset.prevent="resetForm">
|
||||||
<div>
|
<div>
|
||||||
|
@ -105,10 +105,10 @@ function resetForm() {
|
||||||
<input id="desk-input" type="number" v-model="formData.deskNumber" />
|
<input id="desk-input" type="number" v-model="formData.deskNumber" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="removed-checkbox">Removed</label>
|
<label for="removed-checkbox" style="display: inline">Removed</label>
|
||||||
<input id="removed-checkbox" type="checkbox" v-model="formData.removed" />
|
<input id="removed-checkbox" type="checkbox" v-model="formData.removed" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="button-bar">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button type="reset">Cancel</button>
|
<button type="reset">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -18,8 +18,10 @@ const props = defineProps<{
|
||||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
const students: Ref<EntriesResponseStudent[]> = ref([])
|
const students: Ref<EntriesResponseStudent[]> = ref([])
|
||||||
|
|
||||||
const lastSaveState: Ref<string | null> = ref(null)
|
const lastSaveState: Ref<string | null> = ref(null)
|
||||||
const lastSaveStateTimestamp: Ref<number> = ref(0)
|
const lastSaveStateTimestamp: Ref<number> = ref(0)
|
||||||
|
|
||||||
const dates: Ref<string[]> = ref([])
|
const dates: Ref<string[]> = ref([])
|
||||||
const toDate: Ref<Date> = ref(new Date())
|
const toDate: Ref<Date> = ref(new Date())
|
||||||
const fromDate: Ref<Date> = ref(new Date())
|
const fromDate: Ref<Date> = ref(new Date())
|
||||||
|
@ -27,12 +29,12 @@ const fromDate: Ref<Date> = ref(new Date())
|
||||||
const entriesChangedSinceLastSave = computed(() => {
|
const entriesChangedSinceLastSave = computed(() => {
|
||||||
return lastSaveState.value === null || lastSaveState.value !== JSON.stringify(students.value)
|
return lastSaveState.value === null || lastSaveState.value !== JSON.stringify(students.value)
|
||||||
})
|
})
|
||||||
|
const assignedDesks = computed(() => {
|
||||||
|
return students.value.length > 0 && students.value.some(s => s.deskNumber > 0)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
toDate.value.setHours(0, 0, 0, 0)
|
showThisWeek()
|
||||||
fromDate.value.setHours(0, 0, 0, 0)
|
|
||||||
fromDate.value.setDate(fromDate.value.getDate() - 4)
|
|
||||||
await loadEntries()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
|
@ -57,22 +59,27 @@ function shiftDateRange(days: number) {
|
||||||
fromDate.value.setDate(fromDate.value.getDate() + days)
|
fromDate.value.setDate(fromDate.value.getDate() + days)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showPreviousDay() {
|
async function showPreviousWeek() {
|
||||||
shiftDateRange(-1)
|
shiftDateRange(-7)
|
||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showToday() {
|
async function showThisWeek() {
|
||||||
|
// First set the to-date to the next upcoming end-of-week (Friday).
|
||||||
toDate.value = new Date()
|
toDate.value = new Date()
|
||||||
toDate.value.setHours(0, 0, 0, 0)
|
toDate.value.setHours(0, 0, 0, 0)
|
||||||
|
while (toDate.value.getDay() < 5) {
|
||||||
|
toDate.value.setDate(toDate.value.getDate() + 1)
|
||||||
|
}
|
||||||
|
// Then set the from-date to the Monday of that week.
|
||||||
fromDate.value = new Date()
|
fromDate.value = new Date()
|
||||||
fromDate.value.setHours(0, 0, 0, 0)
|
fromDate.value.setHours(0, 0, 0, 0)
|
||||||
fromDate.value.setDate(fromDate.value.getDate() - 4)
|
fromDate.value.setDate(fromDate.value.getDate() - 4)
|
||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showNextDay() {
|
async function showNextWeek() {
|
||||||
shiftDateRange(1)
|
shiftDateRange(7)
|
||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,10 +142,10 @@ function addAllEntriesForDate(dateStr: string) {
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="buttons-bar">
|
<div class="button-bar">
|
||||||
<button type="button" @click="showPreviousDay">Previous Day</button>
|
<button type="button" @click="showPreviousWeek">Previous Week</button>
|
||||||
<button type="button" @click="showToday">Today</button>
|
<button type="button" @click="showThisWeek">This Week</button>
|
||||||
<button type="button" @click="showNextDay">Next Day</button>
|
<button type="button" @click="showNextWeek">Next Week</button>
|
||||||
<button type="button" @click="saveEdits" :disabled="!entriesChangedSinceLastSave">
|
<button type="button" @click="saveEdits" :disabled="!entriesChangedSinceLastSave">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
@ -146,17 +153,19 @@ function addAllEntriesForDate(dateStr: string) {
|
||||||
Discard Edits
|
Discard Edits
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="entries-table">
|
<table class="entries-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Student</th>
|
<th>Student</th>
|
||||||
<th>Desk</th>
|
<th v-if="assignedDesks">Desk</th>
|
||||||
<th v-for="date in dates" :key="date">
|
<th v-for="date in dates" :key="date">
|
||||||
<span>{{ getDate(date).toLocaleDateString() }}</span>
|
<span>{{ getDate(date).toLocaleDateString() }}</span>
|
||||||
<span @click="addAllEntriesForDate(date)">➕</span>
|
<span @click="addAllEntriesForDate(date)">➕</span>
|
||||||
<br />
|
<br />
|
||||||
<span>{{ getWeekday(getDate(date)) }}</span>
|
<span>{{ getWeekday(getDate(date)) }}</span>
|
||||||
</th>
|
</th>
|
||||||
|
<th>Score</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -166,9 +175,15 @@ function addAllEntriesForDate(dateStr: string) {
|
||||||
<span v-text="student.name"></span>
|
<span v-text="student.name"></span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</td>
|
</td>
|
||||||
<td v-text="student.deskNumber"></td>
|
<td v-if="assignedDesks" v-text="student.deskNumber"></td>
|
||||||
<EntryTableCell v-for="(entry, date) in student.entries" :key="date" v-model="student.entries[date]"
|
<EntryTableCell v-for="(entry, date) in student.entries" :key="date" v-model="student.entries[date]"
|
||||||
:date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp" />
|
:date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp" />
|
||||||
|
<td style="text-align: right; padding-right: 0.25em;">
|
||||||
|
<span v-if="student.score" style="font-family: monospace; font-size: large;">
|
||||||
|
{{ (student.score * 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
<span v-if="!student.score" style="font-size: small; font-style: italic;">No score</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -176,7 +191,7 @@ function addAllEntriesForDate(dateStr: string) {
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.entries-table {
|
.entries-table {
|
||||||
margin-top: 1em;
|
margin-top: 0.5em;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
@ -189,15 +204,6 @@ function addAllEntriesForDate(dateStr: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.student-removed {
|
.student-removed {
|
||||||
background-color: lightgray;
|
text-decoration: line-through;
|
||||||
}
|
|
||||||
|
|
||||||
.buttons-bar {
|
|
||||||
margin-top: 0.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons-bar>button+button {
|
|
||||||
margin-left: 0.5em;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -37,6 +37,17 @@ function toggleAbsence() {
|
||||||
// Populate default additional data if student is no longer absent.
|
// Populate default additional data if student is no longer absent.
|
||||||
model.value.phone = { compliant: true }
|
model.value.phone = { compliant: true }
|
||||||
model.value.behavior = { rating: 3 }
|
model.value.behavior = { rating: 3 }
|
||||||
|
// If we have an initial entry known, restore data from that.
|
||||||
|
if (initialEntryJson.value) {
|
||||||
|
const initialEntry = JSON.parse(initialEntryJson.value) as Entry
|
||||||
|
if (initialEntry.absent) return
|
||||||
|
if (initialEntry.phone) {
|
||||||
|
model.value.phone = { compliant: initialEntry.phone?.compliant }
|
||||||
|
}
|
||||||
|
if (initialEntry.behavior) {
|
||||||
|
model.value.behavior = { rating: initialEntry.behavior.rating }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -73,28 +84,32 @@ function addEntry() {
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
|
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
|
||||||
<div v-if="model">
|
<div v-if="model" class="cell-container">
|
||||||
<div class="status-item" @click="toggleAbsence">
|
<div>
|
||||||
<span v-if="model.absent">Absent</span>
|
<div class="status-item" @click="toggleAbsence">
|
||||||
<span v-if="!model.absent">Present</span>
|
<span v-if="model.absent" title="Absent">❌</span>
|
||||||
|
<span v-if="!model.absent" title="Present">✅</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
|
||||||
|
<span v-if="model.phone?.compliant" title="Phone Compliant">📱</span>
|
||||||
|
<span v-if="!model.phone?.compliant" title="Phone Non-Compliant">📵</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent">
|
||||||
|
<span v-if="model.behavior?.rating === 3" title="Good Behavior">😇</span>
|
||||||
|
<span v-if="model.behavior?.rating === 2" title="Mediocre Behavior">😐</span>
|
||||||
|
<span v-if="model.behavior?.rating === 1" title="Poor Behavior">😡</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
|
<div>
|
||||||
<span v-if="model.phone?.compliant">📱</span>
|
<div class="status-item" @click="removeEntry">
|
||||||
<span v-if="!model.phone?.compliant">📵</span>
|
<span>🗑️</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent">
|
|
||||||
<span v-if="model.behavior?.rating === 3">😇</span>
|
|
||||||
<span v-if="model.behavior?.rating === 2">😐</span>
|
|
||||||
<span v-if="model.behavior?.rating === 1">😡</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-item" @click="removeEntry">
|
|
||||||
<span>🗑️</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="model === null">
|
<div v-if="model === null">
|
||||||
<div class="status-item" @click="addEntry">
|
<div class="status-item" @click="addEntry">
|
||||||
<span>➕</span>
|
<span>+</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -106,7 +121,6 @@ td {
|
||||||
}
|
}
|
||||||
|
|
||||||
.missing-entry {
|
.missing-entry {
|
||||||
background-color: lightgray;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,6 +139,12 @@ td {
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-item+.status-item {
|
.status-item+.status-item {
|
||||||
margin-left: 0.25em;
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -52,14 +52,12 @@ async function deleteThisStudent() {
|
||||||
<li>Removed: <span v-text="student.removed"></span></li>
|
<li>Removed: <span v-text="student.removed"></span></li>
|
||||||
<li>Desk number: <span v-text="student.deskNumber"></span></li>
|
<li>Desk number: <span v-text="student.deskNumber"></span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<RouterLink
|
|
||||||
:to="
|
<div class="button-bar">
|
||||||
'/classroom-compliance/classes/' + student.classId + '/edit-student?studentId=' + student.id
|
<button type="button"
|
||||||
"
|
@click="router.push(`/classroom-compliance/classes/${student.classId}/edit-student?studentId=${student.id}`)">Edit</button>
|
||||||
>
|
<button type="button" @click="deleteThisStudent">Delete</button>
|
||||||
Edit
|
</div>
|
||||||
</RouterLink>
|
|
||||||
<button type="button" @click="deleteThisStudent">Delete</button>
|
|
||||||
|
|
||||||
<ConfirmDialog ref="deleteConfirmDialog">
|
<ConfirmDialog ref="deleteConfirmDialog">
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-bar {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-bar > button + button {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
form > div + div {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
import '@/assets/base.css'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '@/views/HomeView.vue'
|
import HomeView from '@/views/HomeView.vue'
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import MyAccountView from '@/views/MyAccountView.vue'
|
||||||
|
|
||||||
|
function enforceAuth() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.state) return '/login'
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
@ -13,9 +21,15 @@ const router = createRouter({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
component: LoginView,
|
component: LoginView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/my-account',
|
||||||
|
component: MyAccountView,
|
||||||
|
beforeEnter: [enforceAuth],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/classroom-compliance',
|
path: '/classroom-compliance',
|
||||||
component: () => import('@/apps/classroom_compliance/MainView.vue'),
|
component: () => import('@/apps/classroom_compliance/MainView.vue'),
|
||||||
|
beforeEnter: [enforceAuth],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
|
|
@ -6,5 +6,20 @@
|
||||||
<p>
|
<p>
|
||||||
Welcome to Teacher-Tools, a website with tools that help teachers to manager their classrooms.
|
Welcome to Teacher-Tools, a website with tools that help teachers to manager their classrooms.
|
||||||
</p>
|
</p>
|
||||||
|
<hr>
|
||||||
|
<h2>Applications</h2>
|
||||||
|
<p>
|
||||||
|
The following list of applications are available for you:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<RouterLink to="/classroom-compliance">Classroom Compliance</RouterLink>
|
||||||
|
-
|
||||||
|
Track your students' phone usage and behavior patterns, and calculate weighted grades.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<em>... and more to come soon!</em>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -34,7 +34,7 @@ async function doLogin() {
|
||||||
<label for="password-input">Password</label>
|
<label for="password-input">Password</label>
|
||||||
<input id="password-input" name="password" type="password" v-model="credentials.password" />
|
<input id="password-input" name="password" type="password" v-model="credentials.password" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="button-bar">
|
||||||
<button type="button" @click="doLogin">Login</button>
|
<button type="button" @click="doLogin">Login</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<main v-if="authStore.state">
|
||||||
|
<h1>My Account</h1>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Internal ID</th>
|
||||||
|
<td>{{ authStore.state.user.id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<td>{{ authStore.state.user.username }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Created At</th>
|
||||||
|
<td>{{ new Date(authStore.state.user.createdAt).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Account Locked</th>
|
||||||
|
<td>{{ authStore.state.user.isLocked }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Administrator</th>
|
||||||
|
<td>{{ authStore.state.user.isAdmin }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
</template>
|
Loading…
Reference in New Issue