teacher-tools/api/source/api_modules/classroom_compliance.d

457 lines
16 KiB
D
Raw Normal View History

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 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;
}
struct ClassroomComplianceEntryPhone {
const ulong entryId;
const bool compliant;
}
struct ClassroomComplianceEntryBehavior {
const ulong entryId;
const ubyte rating;
const string comment;
}
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.PUT, STUDENT_PATH, &updateStudent);
handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent);
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &createEntry);
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
}
void createClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
struct ClassPayload {
ushort number;
string schoolYear;
}
auto payload = readJsonPayload!(ClassPayload)(ctx);
auto db = getDb();
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 this 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) {
User user = getUserOrThrow(ctx);
auto db = getDb();
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) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
writeJsonBody(ctx, cls);
}
void deleteClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb();
db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id);
}
ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, in User user) {
ulong classId = ctx.request.getPathParamAs!ulong("classId");
auto db = getDb();
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) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
struct StudentPayload {
string name;
ushort deskNumber;
}
auto payload = readJsonPayload!(StudentPayload)(ctx);
auto db = getDb();
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("Student with that name already exists.");
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.");
}
db.execute(
"INSERT INTO classroom_compliance_student (name, class_id, desk_number) VALUES (?, ?)",
payload.name, cls.id, payload.deskNumber
);
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) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb();
auto students = findAll!(ClassroomComplianceStudent)(
db,
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
cls.id
);
writeJsonBody(ctx, students);
}
void updateStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto student = getStudentOrThrow(ctx, user);
struct StudentUpdatePayload {
string name;
ushort deskNumber;
}
auto payload = readJsonPayload!(StudentUpdatePayload)(ctx);
// If there is nothing to update, quit.
if (
payload.name == student.name
&& payload.deskNumber == student.deskNumber
) return;
// Check that the new name doesn't already exist.
auto db = getDb();
bool newNameExists = 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("Student with that name already exists.");
return;
}
// Check that if a new desk number is assigned, that it's not already assigned to anyone else.
bool newDeskOccupied = payload.deskNumber != 0 && 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 = ? WHERE id = ?",
payload.name,
payload.deskNumber,
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) {
User user = getUserOrThrow(ctx);
auto student = getStudentOrThrow(ctx, user);
auto db = getDb();
db.execute(
"DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?",
student.id,
student.classId
);
}
ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User user) {
ulong classId = ctx.request.getPathParamAs!ulong("classId");
ulong studentId = ctx.request.getPathParamAs!ulong("studentId");
auto db = getDb();
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 createEntry(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
struct EntryPhonePayload {
bool compliant;
}
struct EntryBehaviorPayload {
int rating;
Nullable!string comment;
}
struct EntryPayload {
ulong studentId;
string date;
bool absent;
Nullable!EntryPhonePayload phoneCompliance;
Nullable!EntryBehaviorPayload behaviorCompliance;
}
auto payload = readJsonPayload!(EntryPayload)(ctx);
auto db = getDb();
bool entryAlreadyExists = canFind(
db,
"SELECT id FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
cls.id,
payload.studentId,
payload.date
);
if (entryAlreadyExists) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("An entry already exists for this student and date.");
return;
}
// Insert the entry and its attached entities in a transaction.
db.begin();
try {
db.execute(
"INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent)
VALUES (?, ?, ?, ?, ?)",
cls.id,
payload.studentId,
payload.date,
getUnixTimestampMillis(),
payload.absent
);
ulong entryId = db.lastInsertRowid();
if (!payload.absent && !payload.phoneCompliance.isNull) {
db.execute(
"INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)",
entryId,
payload.phoneCompliance.get().compliant
);
}
if (!payload.absent && !payload.behaviorCompliance.isNull) {
Nullable!string comment = payload.behaviorCompliance.get().comment;
if (!comment.isNull && (comment.get() is null || comment.get().length == 0)) {
comment.nullify();
}
db.execute(
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment) VALUES (?, ?, ?)",
entryId,
payload.behaviorCompliance.get().rating,
comment
);
}
db.commit();
} catch (Exception e) {
db.rollback();
}
}
void getEntries(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, 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;
}
}
infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
auto db = getDb();
const query = "
SELECT
entry.id,
entry.date,
entry.created_at,
entry.absent,
student.id,
student.name,
student.desk_number,
student.removed,
phone.compliant,
behavior.rating,
behavior.comment
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
entry.date ASC,
student.desk_number ASC,
student.name ASC
";
ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
// Serialize the results into a custom-formatted response object.
import std.json;
JSONValue response = JSONValue.emptyObject;
JSONValue[ulong] studentObjects;
foreach (row; result) {
ulong studentId = row.peek!ulong(4);
if (studentId !in studentObjects) {
JSONValue student = JSONValue.emptyObject;
student.object["id"] = JSONValue(row.peek!ulong(4));
student.object["name"] = JSONValue(row.peek!string(5));
student.object["deskNumber"] = JSONValue(row.peek!ushort(6));
student.object["removed"] = JSONValue(row.peek!bool(7));
student.object["entries"] = JSONValue.emptyObject;
studentObjects[studentId] = student;
}
JSONValue studentObj = studentObjects[studentId];
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!string(2));
entry.object["absent"] = JSONValue(row.peek!bool(3));
JSONValue phone = JSONValue(null);
JSONValue behavior = JSONValue(null);
if (!entry.object["absent"].boolean()) {
phone = JSONValue.emptyObject;
phone.object["compliant"] = JSONValue(row.peek!bool(8));
behavior = JSONValue.emptyObject;
behavior.object["rating"] = JSONValue(row.peek!ubyte(9));
behavior.object["comment"] = JSONValue(row.peek!string(10));
}
entry.object["phone"] = phone;
entry.object["behavior"] = behavior;
string dateStr = entry.object["date"].str();
studentObj.object["entries"].object[dateStr] = entry;
}
// 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.values);
string jsonStr = response.toJSON();
ctx.response.writeBodyString(jsonStr, "application/json");
}