729 lines
		
	
	
		
			25 KiB
		
	
	
	
		
			D
		
	
	
	
			
		
		
	
	
			729 lines
		
	
	
		
			25 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;
 | |
| }
 | |
| 
 | |
| 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.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,
 | |
|         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));
 | |
| 
 | |
|         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));
 | |
|         }
 | |
|         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(4);
 | |
|         bool studentFound = false;
 | |
|         foreach (idx, student; students) {
 | |
|             if (student.id == studentId) {
 | |
|                 studentObjects[idx].object["entries"].object[dateStr] = entry;
 | |
|                 studentFound = true;
 | |
|                 break;
 | |
|             }
 | |
|         }
 | |
|         if (!studentFound) {
 | |
|             throw new Exception("Failed to find student.");
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // 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;
 | |
|     // 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;
 | |
|     db.execute(
 | |
|         "INSERT INTO classroom_compliance_entry
 | |
|         (class_id, student_id, date, created_at, absent)
 | |
|         VALUES (?, ?, ?, ?, ?)",
 | |
|         classId, studentId, dateStr, createdAt, absent
 | |
|     );
 | |
|     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;
 | |
|     db.execute(
 | |
|         "UPDATE classroom_compliance_entry
 | |
|         SET absent = ?
 | |
|         WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?",
 | |
|         absent, 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);
 | |
| }
 |