888 lines
31 KiB
D
888 lines
31 KiB
D
module api_modules.classroom_compliance;
|
|
|
|
import handy_httpd;
|
|
import handy_httpd.handlers.path_handler;
|
|
import d2sqlite3;
|
|
import slf4d;
|
|
import std.typecons : Nullable;
|
|
import std.datetime;
|
|
import std.json;
|
|
import std.algorithm;
|
|
import std.array;
|
|
|
|
import db;
|
|
import data_utils;
|
|
import api_modules.auth : User, getUserOrThrow;
|
|
|
|
struct ClassroomComplianceClass {
|
|
const ulong id;
|
|
const ushort number;
|
|
const string schoolYear;
|
|
const ulong userId;
|
|
}
|
|
|
|
struct ClassroomComplianceStudent {
|
|
const ulong id;
|
|
const string name;
|
|
const ulong classId;
|
|
const ushort deskNumber;
|
|
const bool removed;
|
|
}
|
|
|
|
struct ClassroomComplianceEntry {
|
|
const ulong id;
|
|
const ulong classId;
|
|
const ulong studentId;
|
|
const string date;
|
|
const ulong createdAt;
|
|
const bool absent;
|
|
const string comment;
|
|
}
|
|
|
|
struct ClassroomComplianceEntryPhone {
|
|
const ulong entryId;
|
|
const bool compliant;
|
|
}
|
|
|
|
struct ClassroomComplianceEntryBehavior {
|
|
const ulong entryId;
|
|
const ubyte rating;
|
|
}
|
|
|
|
void registerApiEndpoints(PathHandler handler) {
|
|
const ROOT_PATH = "/api/classroom-compliance";
|
|
|
|
handler.addMapping(Method.POST, ROOT_PATH ~ "/classes", &createClass);
|
|
handler.addMapping(Method.GET, ROOT_PATH ~ "/classes", &getClasses);
|
|
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 ~ "/students", &createStudent);
|
|
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);
|
|
const STUDENT_PATH = CLASS_PATH ~ "/students/:studentId:ulong";
|
|
handler.addMapping(Method.GET, STUDENT_PATH, &getStudent);
|
|
handler.addMapping(Method.PUT, STUDENT_PATH, &updateStudent);
|
|
handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent);
|
|
handler.addMapping(Method.PUT, STUDENT_PATH ~ "/class", &moveStudentToOtherClass);
|
|
handler.addMapping(Method.GET, STUDENT_PATH ~ "/entries", &getStudentEntries);
|
|
handler.addMapping(Method.GET, STUDENT_PATH ~ "/overview", &getStudentOverview);
|
|
|
|
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
|
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries);
|
|
}
|
|
|
|
void createClass(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
struct ClassPayload {
|
|
ushort number;
|
|
string schoolYear;
|
|
}
|
|
auto payload = readJsonPayload!(ClassPayload)(ctx);
|
|
const bool classNumberExists = canFind(
|
|
db,
|
|
"SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?",
|
|
payload.number,
|
|
payload.schoolYear,
|
|
user.id
|
|
);
|
|
if (classNumberExists) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("There is already a class with this number, for the same school year.");
|
|
return;
|
|
}
|
|
auto stmt = db.prepare("INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?)");
|
|
stmt.bindAll(payload.number, payload.schoolYear, user.id);
|
|
stmt.execute();
|
|
ulong classId = db.lastInsertRowid();
|
|
auto newClass = findOne!(ClassroomComplianceClass)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_class WHERE id = ? AND user_id = ?",
|
|
classId,
|
|
user.id
|
|
).orElseThrow();
|
|
writeJsonBody(ctx, newClass);
|
|
}
|
|
|
|
void getClasses(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto classes = findAll!(ClassroomComplianceClass)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC",
|
|
user.id
|
|
);
|
|
writeJsonBody(ctx, classes);
|
|
}
|
|
|
|
void getClass(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto cls = getClassOrThrow(ctx, db, user);
|
|
writeJsonBody(ctx, cls);
|
|
}
|
|
|
|
void deleteClass(ref HttpRequestContext ctx) {
|
|
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);
|
|
}
|
|
|
|
ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) {
|
|
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
|
return findOne!(ClassroomComplianceClass)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?",
|
|
user.id,
|
|
classId
|
|
).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
|
}
|
|
|
|
void createStudent(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto cls = getClassOrThrow(ctx, db, user);
|
|
struct StudentPayload {
|
|
string name;
|
|
ushort deskNumber;
|
|
bool removed;
|
|
}
|
|
auto payload = readJsonPayload!(StudentPayload)(ctx);
|
|
bool studentExists = canFind(
|
|
db,
|
|
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
|
payload.name,
|
|
cls.id
|
|
);
|
|
if (studentExists) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("A student with that name already exists in this class.");
|
|
return;
|
|
}
|
|
bool deskAlreadyOccupied = payload.deskNumber != 0 && canFind(
|
|
db,
|
|
"SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?",
|
|
cls.id,
|
|
payload.deskNumber
|
|
);
|
|
if (deskAlreadyOccupied) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("There is already a student assigned to that desk number.");
|
|
}
|
|
db.execute(
|
|
"INSERT INTO classroom_compliance_student (name, class_id, desk_number, removed) VALUES (?, ?, ?, ?)",
|
|
payload.name, cls.id, payload.deskNumber, payload.removed
|
|
);
|
|
ulong studentId = db.lastInsertRowid();
|
|
auto student = findOne!(ClassroomComplianceStudent)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_student WHERE id = ?",
|
|
studentId
|
|
).orElseThrow();
|
|
writeJsonBody(ctx, student);
|
|
}
|
|
|
|
void getStudents(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto cls = getClassOrThrow(ctx, db, user);
|
|
auto students = findAll!(ClassroomComplianceStudent)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
|
cls.id
|
|
);
|
|
writeJsonBody(ctx, students);
|
|
}
|
|
|
|
void getStudent(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto student = getStudentOrThrow(ctx, db, user);
|
|
writeJsonBody(ctx, student);
|
|
}
|
|
|
|
void updateStudent(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto student = getStudentOrThrow(ctx, db, user);
|
|
struct StudentUpdatePayload {
|
|
string name;
|
|
ushort deskNumber;
|
|
bool removed;
|
|
}
|
|
auto payload = readJsonPayload!(StudentUpdatePayload)(ctx);
|
|
// If there is nothing to update, quit.
|
|
if (
|
|
payload.name == student.name
|
|
&& payload.deskNumber == student.deskNumber
|
|
&& payload.removed == student.removed
|
|
) return;
|
|
// Check that the new name doesn't already exist.
|
|
bool newNameExists = payload.name != student.name && canFind(
|
|
db,
|
|
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
|
payload.name,
|
|
student.classId
|
|
);
|
|
if (newNameExists) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("A student with that name already exists in this class.");
|
|
return;
|
|
}
|
|
// Check that if a new desk number is assigned, that it's not already assigned to anyone else.
|
|
bool newDeskOccupied = payload.deskNumber != 0 && payload.deskNumber != student.deskNumber && canFind(
|
|
db,
|
|
"SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?",
|
|
student.classId,
|
|
payload.deskNumber
|
|
);
|
|
if (newDeskOccupied) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("That desk is already assigned to another student.");
|
|
return;
|
|
}
|
|
db.execute(
|
|
"UPDATE classroom_compliance_student SET name = ?, desk_number = ?, removed = ? WHERE id = ?",
|
|
payload.name,
|
|
payload.deskNumber,
|
|
payload.removed,
|
|
student.id
|
|
);
|
|
auto updatedStudent = findOne!(ClassroomComplianceStudent)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_student WHERE id = ?",
|
|
student.id
|
|
).orElseThrow();
|
|
writeJsonBody(ctx, updatedStudent);
|
|
}
|
|
|
|
void deleteStudent(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto student = getStudentOrThrow(ctx, db, user);
|
|
db.execute(
|
|
"DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?",
|
|
student.id,
|
|
student.classId
|
|
);
|
|
}
|
|
|
|
ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) {
|
|
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
|
ulong studentId = ctx.request.getPathParamAs!ulong("studentId");
|
|
string query = "
|
|
SELECT s.*
|
|
FROM classroom_compliance_student s
|
|
LEFT JOIN classroom_compliance_class c ON s.class_id = c.id
|
|
WHERE s.id = ? AND s.class_id = ? AND c.user_id = ?
|
|
";
|
|
return findOne!(ClassroomComplianceStudent)(
|
|
db,
|
|
query,
|
|
studentId,
|
|
classId,
|
|
user.id
|
|
).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
|
}
|
|
|
|
void getEntries(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto cls = getClassOrThrow(ctx, db, user);
|
|
// Default to getting entries from the last 5 days.
|
|
SysTime now = Clock.currTime();
|
|
Date toDate = Date(now.year, now.month, now.day);
|
|
Date fromDate = toDate - days(4);
|
|
if (ctx.request.queryParams.contains("to")) {
|
|
try {
|
|
toDate = Date.fromISOExtString(ctx.request.queryParams.getFirst("to").orElse(""));
|
|
} catch (DateTimeException e) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("Invalid \"to\" date.");
|
|
return;
|
|
}
|
|
}
|
|
if (ctx.request.queryParams.contains("from")) {
|
|
try {
|
|
fromDate = Date.fromISOExtString(ctx.request.queryParams.getFirst("from").orElse(""));
|
|
} catch (DateTimeException e) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("Invalid \"from\" date.");
|
|
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());
|
|
|
|
// First prepare a list of all students, including ones which don't have any entries.
|
|
ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
|
cls.id
|
|
);
|
|
JSONValue[] studentObjects = students.map!((s) {
|
|
JSONValue obj = JSONValue.emptyObject;
|
|
obj.object["id"] = JSONValue(s.id);
|
|
obj.object["deskNumber"] = JSONValue(s.deskNumber);
|
|
obj.object["name"] = JSONValue(s.name);
|
|
obj.object["removed"] = JSONValue(s.removed);
|
|
obj.object["entries"] = JSONValue.emptyObject;
|
|
obj.object["score"] = JSONValue(null);
|
|
return obj;
|
|
}).array;
|
|
|
|
const entriesQuery = "
|
|
SELECT
|
|
entry.id,
|
|
entry.date,
|
|
entry.created_at,
|
|
entry.absent,
|
|
entry.comment,
|
|
student.id,
|
|
student.name,
|
|
student.desk_number,
|
|
student.removed,
|
|
phone.compliant,
|
|
behavior.rating
|
|
FROM classroom_compliance_entry entry
|
|
LEFT JOIN classroom_compliance_entry_phone phone
|
|
ON phone.entry_id = entry.id
|
|
LEFT JOIN classroom_compliance_entry_behavior behavior
|
|
ON behavior.entry_id = entry.id
|
|
LEFT JOIN classroom_compliance_student student
|
|
ON student.id = entry.student_id
|
|
WHERE
|
|
entry.class_id = ?
|
|
AND entry.date >= ?
|
|
AND entry.date <= ?
|
|
ORDER BY
|
|
student.id ASC,
|
|
entry.date ASC
|
|
";
|
|
ResultRange entriesResult = db.execute(entriesQuery, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
|
|
// Serialize the results into a custom-formatted response object.
|
|
foreach (row; entriesResult) {
|
|
JSONValue entry = JSONValue.emptyObject;
|
|
entry.object["id"] = JSONValue(row.peek!ulong(0));
|
|
entry.object["date"] = JSONValue(row.peek!string(1));
|
|
entry.object["createdAt"] = JSONValue(row.peek!ulong(2));
|
|
entry.object["absent"] = JSONValue(row.peek!bool(3));
|
|
entry.object["comment"] = JSONValue(row.peek!string(4));
|
|
|
|
JSONValue phone = JSONValue(null);
|
|
JSONValue behavior = JSONValue(null);
|
|
if (!entry.object["absent"].boolean()) {
|
|
phone = JSONValue.emptyObject;
|
|
phone.object["compliant"] = JSONValue(row.peek!bool(9));
|
|
behavior = JSONValue.emptyObject;
|
|
behavior.object["rating"] = JSONValue(row.peek!ubyte(10));
|
|
}
|
|
entry.object["phone"] = phone;
|
|
entry.object["behavior"] = behavior;
|
|
string dateStr = entry.object["date"].str();
|
|
|
|
// Find the student object this entry belongs to, then add it to their list.
|
|
ulong studentId = row.peek!ulong(5);
|
|
bool studentFound = false;
|
|
foreach (idx, studentObj; studentObjects) {
|
|
if (studentObj.object["id"].uinteger == studentId) {
|
|
studentObj.object["entries"].object[dateStr] = entry;
|
|
studentFound = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!studentFound) {
|
|
// The student isn't in our list of original students from the class, so it's a student who's moved to another.
|
|
JSONValue obj = JSONValue.emptyObject;
|
|
obj.object["id"] = JSONValue(studentId);
|
|
obj.object["deskNumber"] = JSONValue(row.peek!ushort(7));
|
|
obj.object["name"] = JSONValue(row.peek!string(6));
|
|
obj.object["removed"] = JSONValue(row.peek!bool(8));
|
|
obj.object["entries"] = JSONValue.emptyObject;
|
|
obj.object["entries"].object[dateStr] = entry;
|
|
obj.object["score"] = JSONValue(null);
|
|
studentObjects ~= obj;
|
|
}
|
|
}
|
|
|
|
// 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 (studentObj; studentObjects) {
|
|
if (studentObj.object["id"].uinteger == studentId) {
|
|
studentObj.object["score"] = scoreValue;
|
|
studentFound = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!studentFound) {
|
|
throw new Exception("Failed to find student.");
|
|
}
|
|
}
|
|
|
|
JSONValue response = JSONValue.emptyObject;
|
|
// Provide the list of dates that we're providing data for, to make it easier for the frontend.
|
|
response.object["dates"] = JSONValue.emptyArray;
|
|
|
|
// Also fill in "null" for any students that don't have an entry on one of these days.
|
|
Date d = fromDate;
|
|
while (d <= toDate) {
|
|
string dateStr = d.toISOExtString();
|
|
response.object["dates"].array ~= JSONValue(dateStr);
|
|
foreach (studentObj; studentObjects) {
|
|
if (dateStr !in studentObj.object["entries"].object) {
|
|
studentObj.object["entries"].object[dateStr] = JSONValue(null);
|
|
}
|
|
}
|
|
d += days(1);
|
|
}
|
|
response.object["students"] = JSONValue(studentObjects);
|
|
|
|
string jsonStr = response.toJSON();
|
|
ctx.response.writeBodyString(jsonStr, "application/json");
|
|
}
|
|
|
|
void saveEntries(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto cls = getClassOrThrow(ctx, db, user);
|
|
JSONValue bodyContent = ctx.request.readBodyAsJson();
|
|
db.begin();
|
|
try {
|
|
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
|
|
ulong studentId = studentObj.object["id"].integer();
|
|
JSONValue entries = studentObj.object["entries"];
|
|
foreach (string dateStr, JSONValue entry; entries.object) {
|
|
if (entry.isNull) {
|
|
deleteEntry(db, cls.id, studentId, dateStr);
|
|
continue;
|
|
}
|
|
|
|
Optional!ClassroomComplianceEntry existingEntry = findOne!(ClassroomComplianceEntry)(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
|
cls.id, studentId, dateStr
|
|
);
|
|
|
|
ulong entryId = entry.object["id"].integer();
|
|
bool creatingNewEntry = entryId == 0;
|
|
|
|
if (creatingNewEntry) {
|
|
if (!existingEntry.isNull) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("Cannot create a new entry when one already exists.");
|
|
return;
|
|
}
|
|
|
|
insertNewEntry(db, cls.id, studentId, dateStr, entry);
|
|
} else {
|
|
if (existingEntry.isNull) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("Cannot update an entry which doesn't exist.");
|
|
return;
|
|
}
|
|
updateEntry(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);
|
|
}
|
|
}
|
|
|
|
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(
|
|
ref Database db,
|
|
ulong classId,
|
|
ulong studentId,
|
|
string dateStr,
|
|
JSONValue payload
|
|
) {
|
|
ulong createdAt = getUnixTimestampMillis();
|
|
bool absent = payload.object["absent"].boolean;
|
|
string comment = payload.object["comment"].str;
|
|
if (comment is null) comment = "";
|
|
db.execute(
|
|
"INSERT INTO classroom_compliance_entry
|
|
(class_id, student_id, date, created_at, absent, comment)
|
|
VALUES (?, ?, ?, ?, ?, ?)",
|
|
classId, studentId, dateStr, createdAt, absent, comment
|
|
);
|
|
if (!absent) {
|
|
ulong entryId = db.lastInsertRowid();
|
|
if ("phone" !in payload.object || payload.object["phone"].type != JSONType.object) {
|
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing phone data.");
|
|
}
|
|
if ("behavior" !in payload.object || payload.object["behavior"].type != JSONType.object) {
|
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing behavior data.");
|
|
}
|
|
bool phoneCompliance = payload.object["phone"].object["compliant"].boolean;
|
|
db.execute(
|
|
"INSERT INTO classroom_compliance_entry_phone (entry_id, compliant)
|
|
VALUES (?, ?)",
|
|
entryId, phoneCompliance
|
|
);
|
|
ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer;
|
|
db.execute(
|
|
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating)
|
|
VALUES (?, ?)",
|
|
entryId, behaviorRating
|
|
);
|
|
}
|
|
infoF!"Created new entry for student %d: %s"(studentId, payload);
|
|
}
|
|
|
|
private void updateEntry(
|
|
ref Database db,
|
|
ulong classId,
|
|
ulong studentId,
|
|
string dateStr,
|
|
ulong entryId,
|
|
JSONValue obj
|
|
) {
|
|
bool absent = obj.object["absent"].boolean;
|
|
string comment = obj.object["comment"].str;
|
|
if (comment is null) comment = "";
|
|
db.execute(
|
|
"UPDATE classroom_compliance_entry
|
|
SET absent = ?, comment = ?
|
|
WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?",
|
|
absent, comment,
|
|
classId, studentId, dateStr, entryId
|
|
);
|
|
if (absent) {
|
|
db.execute(
|
|
"DELETE FROM classroom_compliance_entry_phone WHERE entry_id = ?",
|
|
entryId
|
|
);
|
|
db.execute(
|
|
"DELETE FROM classroom_compliance_entry_behavior WHERE entry_id = ?",
|
|
entryId
|
|
);
|
|
} else {
|
|
bool phoneCompliant = obj.object["phone"].object["compliant"].boolean;
|
|
bool phoneDataExists = canFind(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_entry_phone WHERE entry_id = ?",
|
|
entryId
|
|
);
|
|
|
|
if (phoneDataExists) {
|
|
db.execute(
|
|
"UPDATE classroom_compliance_entry_phone
|
|
SET compliant = ?
|
|
WHERE entry_id = ?",
|
|
phoneCompliant,
|
|
entryId
|
|
);
|
|
} else {
|
|
db.execute(
|
|
"INSERT INTO classroom_compliance_entry_phone (entry_id, compliant)
|
|
VALUES (?, ?)",
|
|
entryId, phoneCompliant
|
|
);
|
|
}
|
|
|
|
ubyte behaviorRating = cast(ubyte) obj.object["behavior"].object["rating"].integer;
|
|
bool behaviorDataExists = canFind(
|
|
db,
|
|
"SELECT * FROM classroom_compliance_entry_behavior WHERE entry_id = ?",
|
|
entryId
|
|
);
|
|
if (behaviorDataExists) {
|
|
db.execute(
|
|
"UPDATE classroom_compliance_entry_behavior
|
|
SET rating = ?
|
|
WHERE entry_id = ?",
|
|
behaviorRating,
|
|
entryId
|
|
);
|
|
} else {
|
|
db.execute(
|
|
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating)
|
|
VALUES (?, ?)",
|
|
entryId, behaviorRating
|
|
);
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
|
|
void moveStudentToOtherClass(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto student = getStudentOrThrow(ctx, db, user);
|
|
struct Payload {
|
|
ulong classId;
|
|
}
|
|
Payload payload = readJsonPayload!(Payload)(ctx);
|
|
if (payload.classId == student.classId) {
|
|
return; // Quit if the student is already in the desired class.
|
|
}
|
|
// Check that the desired class exists, and belongs to the user.
|
|
bool newClassIdValid = canFind(
|
|
db,
|
|
"SELECT id FROM classroom_compliance_class WHERE user_id = ? and id = ?",
|
|
user.id, payload.classId
|
|
);
|
|
if (!newClassIdValid) {
|
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
ctx.response.writeBodyString("Invalid class was selected.");
|
|
return;
|
|
}
|
|
// All good, so update the student's class to the desired one, and reset their desk.
|
|
db.execute(
|
|
"UPDATE classroom_compliance_student SET class_id = ?, desk_number = 0 WHERE id = ?",
|
|
payload.classId,
|
|
student.id
|
|
);
|
|
// We just return 200 OK, no response body.
|
|
}
|
|
|
|
void getStudentEntries(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto student = getStudentOrThrow(ctx, db, user);
|
|
|
|
const query = "
|
|
SELECT
|
|
e.id,
|
|
e.date,
|
|
e.created_at,
|
|
e.absent,
|
|
e.comment,
|
|
p.compliant,
|
|
b.rating
|
|
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.student_id = ?
|
|
ORDER BY e.date DESC
|
|
";
|
|
JSONValue response = JSONValue.emptyArray;
|
|
foreach (row; db.execute(query, student.id)) {
|
|
JSONValue e = JSONValue.emptyObject;
|
|
bool absent = row.peek!bool(3);
|
|
e.object["id"] = JSONValue(row.peek!ulong(0));
|
|
e.object["date"] = JSONValue(row.peek!string(1));
|
|
e.object["createdAt"] = JSONValue(row.peek!ulong(2));
|
|
e.object["absent"] = JSONValue(absent);
|
|
e.object["comment"] = JSONValue(row.peek!string(4));
|
|
if (absent) {
|
|
e.object["phone"] = JSONValue(null);
|
|
e.object["behavior"] = JSONValue(null);
|
|
} else {
|
|
JSONValue phone = JSONValue.emptyObject;
|
|
phone.object["compliant"] = JSONValue(row.peek!bool(5));
|
|
e.object["phone"] = phone;
|
|
JSONValue behavior = JSONValue.emptyObject;
|
|
behavior.object["rating"] = JSONValue(row.peek!ubyte(6));
|
|
e.object["behavior"] = behavior;
|
|
}
|
|
response.array ~= e;
|
|
}
|
|
ctx.response.writeBodyString(response.toJSON(), "application/json");
|
|
}
|
|
|
|
void getStudentOverview(ref HttpRequestContext ctx) {
|
|
auto db = getDb();
|
|
User user = getUserOrThrow(ctx, db);
|
|
auto student = getStudentOrThrow(ctx, db, user);
|
|
|
|
const ulong entryCount = findOne!ulong(
|
|
db,
|
|
"SELECT COUNT(*) FROM classroom_compliance_entry WHERE student_id = ?",
|
|
student.id
|
|
).orElse(0);
|
|
if (entryCount == 0) {
|
|
ctx.response.status = HttpStatus.NOT_FOUND;
|
|
ctx.response.writeBodyString("No entries for this student.");
|
|
return;
|
|
}
|
|
const ulong absenceCount = findOne!ulong(
|
|
db,
|
|
"SELECT COUNT(*) FROM classroom_compliance_entry WHERE student_id = ? AND absent = true",
|
|
student.id
|
|
).orElse(0);
|
|
const ulong phoneNoncomplianceCount = findOne!ulong(
|
|
db,
|
|
"
|
|
SELECT COUNT(*)
|
|
FROM classroom_compliance_entry_phone p
|
|
LEFT JOIN classroom_compliance_entry e
|
|
ON e.id = p.entry_id
|
|
WHERE p.compliant = false AND e.student_id = ?
|
|
",
|
|
student.id
|
|
).orElse(0);
|
|
const behaviorCountQuery = "
|
|
SELECT COUNT(*)
|
|
FROM classroom_compliance_entry_behavior b
|
|
LEFT JOIN classroom_compliance_entry e
|
|
ON e.id = b.entry_id
|
|
WHERE e.student_id = ? AND b.rating = ?
|
|
";
|
|
|
|
const ulong behaviorGoodCount = findOne!ulong(db, behaviorCountQuery, student.id, 3).orElse(0);
|
|
const ulong behaviorMediocreCount = findOne!ulong(db, behaviorCountQuery, student.id, 2).orElse(0);
|
|
const ulong behaviorPoorCount = findOne!ulong(db, behaviorCountQuery, student.id, 1).orElse(0);
|
|
|
|
// Calculate derived statistics.
|
|
const ulong attendanceCount = entryCount - absenceCount;
|
|
double attendanceRate = attendanceCount / cast(double) entryCount;
|
|
double phoneComplianceRate = (attendanceCount - phoneNoncomplianceCount) / cast(double) attendanceCount;
|
|
double behaviorScore = (
|
|
behaviorGoodCount * 1.0 +
|
|
behaviorMediocreCount * 0.5
|
|
) / attendanceCount;
|
|
|
|
JSONValue response = JSONValue.emptyObject;
|
|
response.object["attendanceRate"] = JSONValue(attendanceRate);
|
|
response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate);
|
|
response.object["behaviorScore"] = JSONValue(behaviorScore);
|
|
response.object["entryCount"] = JSONValue(entryCount);
|
|
ctx.response.writeBodyString(response.toJSON(), "application/json");
|
|
}
|