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

355 lines
12 KiB
D

module api_modules.classroom_compliance.api_entry;
import handy_httpd;
import handy_httpd.components.optional;
import ddbc;
import std.datetime;
import std.json;
import std.algorithm : map;
import std.array;
import slf4d;
import api_modules.auth;
import api_modules.classroom_compliance.model;
import api_modules.classroom_compliance.util;
import api_modules.classroom_compliance.score;
import db;
import data_utils;
struct EntriesTableEntryChecklistItem {
string item;
bool checked;
string category;
}
struct EntriesTableEntry {
Date date;
ulong createdAt;
bool absent;
string comment;
EntriesTableEntryChecklistItem[] checklistItems;
JSONValue toJsonObj() const {
JSONValue obj = JSONValue.emptyObject;
obj.object["date"] = JSONValue(date.toISOExtString());
obj.object["createdAt"] = JSONValue(createdAt);
obj.object["absent"] = JSONValue(absent);
obj.object["comment"] = JSONValue(comment);
obj.object["checklistItems"] = JSONValue.emptyArray;
foreach (ck; checklistItems) {
JSONValue ckObj = JSONValue.emptyObject;
ckObj.object["item"] = JSONValue(ck.item);
ckObj.object["checked"] = JSONValue(ck.checked);
ckObj.object["category"] = JSONValue(ck.category);
obj.object["checklistItems"].array ~= ckObj;
}
return obj;
}
}
struct EntriesTableStudentResponse {
ulong id;
ulong classId;
string name;
ushort deskNumber;
bool removed;
EntriesTableEntry[string] entries;
Optional!double score;
JSONValue toJsonObj() const {
JSONValue obj = JSONValue.emptyObject;
obj.object["id"] = JSONValue(id);
obj.object["classId"] = JSONValue(classId);
obj.object["name"] = JSONValue(name);
obj.object["deskNumber"] = JSONValue(deskNumber);
obj.object["removed"] = JSONValue(removed);
JSONValue entriesSet = JSONValue.emptyObject;
foreach (dateStr, entry; entries) {
entriesSet.object[dateStr] = entry.toJsonObj();
}
obj.object["entries"] = entriesSet;
if (score.isNull) {
obj.object["score"] = JSONValue(null);
} else {
obj.object["score"] = JSONValue(score.value);
}
return obj;
}
}
struct EntriesTableResponse {
EntriesTableStudentResponse[] students;
string[] dates;
JSONValue toJsonObj() const {
JSONValue obj = JSONValue.emptyObject;
obj.object["students"] = JSONValue(students.map!(s => s.toJsonObj()).array);
obj.object["dates"] = JSONValue(dates);
return obj;
}
}
/**
* Main endpoint that supplies data for the app's "entries" table, which shows
* all data about all students in a class, usually for a selected week. Here,
* we need to provide a list of students which will be treated as rows by the
* table, and then for each student, an entry object for each date in the
* requested date range.
* Params:
* ctx = The request context.
*/
void getEntries(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
DateRange dateRange = parseDateRangeParams(ctx);
// First prepare a list of all students, including ones which don't have any entries.
ClassroomComplianceStudent[] students = findAll(
conn,
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
&ClassroomComplianceStudent.parse,
cls.id
);
EntriesTableStudentResponse[] studentObjects = students.map!(s => EntriesTableStudentResponse(
s.id,
s.classId,
s.name,
s.deskNumber,
s.removed,
null,
Optional!double.empty
)).array;
const entriesQuery = import("source/api_modules/classroom_compliance/queries/find_entries_by_class.sql");
PreparedStatement ps = conn.prepareStatement(entriesQuery);
scope(exit) ps.close();
ps.setUlong(1, cls.id);
ps.setDate(2, dateRange.from);
ps.setDate(3, dateRange.to);
ResultSet rs = ps.executeQuery();
scope(exit) rs.close();
ClassroomComplianceStudent student;
EntriesTableEntry entry;
bool hasNextRow = rs.next();
while (hasNextRow) {
// Parse the basic data from the query.
student.id = rs.getUlong(1);
student.name = rs.getString(2);
student.classId = cls.id;
student.deskNumber = rs.getUshort(3);
student.removed = rs.getBoolean(4);
entry.date = rs.getDate(5);
entry.createdAt = rs.getUlong(6);
entry.absent = rs.getBoolean(7);
entry.comment = rs.getString(8);
bool hasChecklistItem = !rs.isNull(9);
if (hasChecklistItem) {
entry.checklistItems ~= EntriesTableEntryChecklistItem(
rs.getString(9),
rs.getBoolean(10),
rs.getString(11)
);
}
// Load in the next row, and if it's the end of the result set or a new entry, we save this one.
hasNextRow = rs.next();
bool shouldSaveEntry = !hasNextRow || (
rs.getUlong(1) != student.id ||
rs.getDate(5) != entry.date
);
if (shouldSaveEntry) {
// Save the data for the current student and entry, including all checklist items.
// Then proceed to read the next item.
string dateStr = entry.date.toISOExtString();
// Find the student object this entry belongs to, then add it to their list.
bool studentFound = false;
foreach (ref studentObj; studentObjects) {
if (studentObj.id == student.id) {
studentObj.entries[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 has since moved to another class.
// Their data should still be shown, so add the student here.
studentObjects ~= EntriesTableStudentResponse(
student.id,
student.classId,
student.name,
student.deskNumber,
student.removed,
[dateStr: entry],
Optional!double.empty
);
}
// Finally, reset the entry's list of checklist items.
entry.checklistItems = [];
}
}
// Find scores for each student for this timeframe.
Optional!double[ulong] scores = getScores(conn, cls.id, dateRange.to);
foreach (studentId, score; scores) {
bool studentFound = false;
foreach (ref studentObj; studentObjects) {
if (studentObj.id == studentId) {
studentObj.score = score;
studentFound = true;
break;
}
}
if (!studentFound) {
throw new Exception("Failed to find student for which a score was calculated.");
}
}
// Prepare the final response to the client:
EntriesTableResponse response;
Date d = dateRange.from;
while (d <= dateRange.to) {
string dateStr = d.toISOExtString();
response.dates ~= dateStr;
d += days(1);
}
response.students = studentObjects;
JSONValue responseObj = response.toJsonObj();
// Go back and add null to any dates any student is missing an entry for.
foreach (ref studentObj; responseObj.object["students"].array) {
foreach (dateStr; response.dates) {
if (dateStr !in studentObj.object["entries"].object) {
studentObj.object["entries"].object[dateStr] = JSONValue(null);
}
}
}
ctx.response.writeBodyString(responseObj.toJSON(), "application/json");
}
/**
* Endpoint for the user to save changes to any entries they've edited. The
* user provides a JSON payload containing the updated entries, and we go
* through and perform updates to the database to match the desired state.
* Params:
* ctx = The request context.
*/
void saveEntries(ref HttpRequestContext ctx) {
Connection conn = getDb();
conn.setAutoCommit(false);
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
JSONValue bodyContent = ctx.request.readBodyAsJson();
try {
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
ulong studentId = studentObj.object["id"].integer();
JSONValue entries = studentObj.object["entries"];
foreach (string dateStr, JSONValue entry; entries.object) {
// Always start by deleting the existing entry to overwrite it with the new one.
deleteEntry(conn, cls.id, studentId, dateStr);
if (!entry.isNull) {
insertEntry(conn, cls.id, studentId, dateStr, entry);
}
}
}
conn.commit();
} catch (HttpStatusException e) {
conn.rollback();
ctx.response.status = e.status;
ctx.response.writeBodyString(e.message);
} catch (JSONException e) {
conn.rollback();
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid JSON payload.");
warn(e);
} catch (Exception e) {
conn.rollback();
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg);
error(e);
}
}
private void deleteEntry(
Connection conn,
ulong classId,
ulong studentId,
string dateStr
) {
update(
conn,
"DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
classId, studentId, dateStr
);
}
private void insertEntry(
Connection conn,
ulong classId,
ulong studentId,
string dateStr,
JSONValue payload
) {
bool absent = payload.object["absent"].boolean;
string comment = payload.object["comment"].str;
if (comment is null) comment = "";
EntriesTableEntryChecklistItem[] checklistItems;
if ("checklistItems" in payload.object) {
checklistItems = payload.object["checklistItems"].array
.map!((obj) {
EntriesTableEntryChecklistItem ck;
ck.item = obj.object["item"].str;
ck.checked = obj.object["checked"].boolean;
ck.category = obj.object["category"].str;
return ck;
})
.array;
}
// If absent, ensure no checklist items may be checked.
if (absent) {
foreach (ref ck; checklistItems) {
ck.checked = false;
}
}
// Do the main insert first.
const query = "
INSERT INTO classroom_compliance_entry
(class_id, student_id, date, absent, comment)
VALUES (?, ?, ?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setUlong(1, classId);
ps.setUlong(2, studentId);
ps.setString(3, dateStr);
ps.setBoolean(4, absent);
ps.setString(5, comment);
ps.executeUpdate();
// Now insert checklist items, if any.
if (checklistItems.length > 0) {
const ckQuery = "
INSERT INTO classroom_compliance_entry_checklist_item
(class_id, student_id, date, item, checked, category)
VALUES (?, ?, ?, ?, ?, ?)";
PreparedStatement ckPs = conn.prepareStatement(ckQuery);
scope(exit) ckPs.close();
foreach (ck; checklistItems) {
ckPs.setUlong(1, classId);
ckPs.setUlong(2, studentId);
ckPs.setString(3, dateStr);
ckPs.setString(4, ck.item);
ckPs.setBoolean(5, ck.checked);
ckPs.setString(6, ck.category);
ckPs.executeUpdate();
}
}
}