Added entries table, and entries endpoint.
This commit is contained in:
parent
e7683a5c9d
commit
7166b995f7
|
@ -9,24 +9,16 @@ CREATE TABLE classroom_compliance_class (
|
|||
CREATE TABLE classroom_compliance_student (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE classroom_compliance_desk_assignment (
|
||||
id INTEGER PRIMARY KEY,
|
||||
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
desk_number INTEGER NOT NULL,
|
||||
student_id INTEGER REFERENCES classroom_compliance_student(id)
|
||||
ON UPDATE CASCADE ON DELETE SET NULL
|
||||
desk_number INTEGER NOT NULL DEFAULT 0,
|
||||
removed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE classroom_compliance_entry (
|
||||
id INTEGER PRIMARY KEY,
|
||||
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
class_description TEXT NOT NULL,
|
||||
student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id),
|
||||
date TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
-- TEST DATA
|
||||
|
||||
-- username: test, password: test
|
||||
INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES (
|
||||
'test',
|
||||
'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08',
|
||||
1734380300,
|
||||
0,
|
||||
1
|
||||
);
|
||||
INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES (
|
||||
'test2',
|
||||
'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08',
|
||||
1734394062,
|
||||
0,
|
||||
0
|
||||
);
|
|
@ -2,8 +2,10 @@ module api_modules.classroom_compliance;
|
|||
|
||||
import handy_httpd;
|
||||
import handy_httpd.handlers.path_handler;
|
||||
import std.typecons : Nullable;
|
||||
import d2sqlite3;
|
||||
import slf4d;
|
||||
import std.typecons : Nullable;
|
||||
import std.datetime;
|
||||
|
||||
import db;
|
||||
import data_utils;
|
||||
|
@ -20,19 +22,13 @@ struct ClassroomComplianceStudent {
|
|||
const ulong id;
|
||||
const string name;
|
||||
const ulong classId;
|
||||
}
|
||||
|
||||
struct ClassroomComplianceDeskAssignment {
|
||||
const ulong id;
|
||||
const ulong classId;
|
||||
const ushort deskNumber;
|
||||
const ulong studentId;
|
||||
const bool removed;
|
||||
}
|
||||
|
||||
struct ClassroomComplianceEntry {
|
||||
const ulong id;
|
||||
const ulong classId;
|
||||
const string classDescription;
|
||||
const ulong studentId;
|
||||
const string date;
|
||||
const ulong createdAt;
|
||||
|
@ -56,6 +52,7 @@ void registerApiEndpoints(PathHandler handler) {
|
|||
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);
|
||||
|
@ -64,11 +61,8 @@ void registerApiEndpoints(PathHandler handler) {
|
|||
handler.addMapping(Method.PUT, STUDENT_PATH, &updateStudent);
|
||||
handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent);
|
||||
|
||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/desk-assignments", &setDeskAssignments);
|
||||
handler.addMapping(Method.GET, CLASS_PATH ~ "/desk-assignments", &getDeskAssignments);
|
||||
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/desk-assignments", &removeAllDeskAssignments);
|
||||
|
||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &createEntry);
|
||||
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
|
||||
}
|
||||
|
||||
void createClass(ref HttpRequestContext ctx) {
|
||||
|
@ -115,6 +109,12 @@ void getClasses(ref HttpRequestContext ctx) {
|
|||
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);
|
||||
|
@ -138,6 +138,7 @@ void createStudent(ref HttpRequestContext ctx) {
|
|||
auto cls = getClassOrThrow(ctx, user);
|
||||
struct StudentPayload {
|
||||
string name;
|
||||
ushort deskNumber;
|
||||
}
|
||||
auto payload = readJsonPayload!(StudentPayload)(ctx);
|
||||
auto db = getDb();
|
||||
|
@ -152,7 +153,20 @@ void createStudent(ref HttpRequestContext ctx) {
|
|||
ctx.response.writeBodyString("Student with that name already exists.");
|
||||
return;
|
||||
}
|
||||
db.execute("INSERT INTO classroom_compliance_student (name, class_id) VALUES (?, ?)", payload.name, cls.id);
|
||||
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,
|
||||
|
@ -179,10 +193,14 @@ void updateStudent(ref HttpRequestContext 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) return;
|
||||
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(
|
||||
|
@ -196,9 +214,22 @@ void updateStudent(ref HttpRequestContext ctx) {
|
|||
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 = ? WHERE id = ?",
|
||||
"UPDATE classroom_compliance_student SET name = ?, desk_number = ? WHERE id = ?",
|
||||
payload.name,
|
||||
payload.deskNumber,
|
||||
student.id
|
||||
);
|
||||
auto updatedStudent = findOne!(ClassroomComplianceStudent)(
|
||||
|
@ -239,135 +270,187 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User
|
|||
).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
private struct DeskAssignmentPayloadEntry {
|
||||
ushort deskNumber;
|
||||
Nullable!ulong studentId;
|
||||
}
|
||||
private struct DeskAssignmentPayload {
|
||||
DeskAssignmentPayloadEntry[] entries;
|
||||
}
|
||||
|
||||
void setDeskAssignments(ref HttpRequestContext ctx) {
|
||||
void createEntry(ref HttpRequestContext ctx) {
|
||||
User user = getUserOrThrow(ctx);
|
||||
auto cls = getClassOrThrow(ctx, user);
|
||||
auto payload = readJsonPayload!(DeskAssignmentPayload)(ctx);
|
||||
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();
|
||||
auto validationError = validateDeskAssignments(db, payload, cls.id);
|
||||
if (validationError) {
|
||||
import slf4d;
|
||||
warnF!"Desk assignment validation failed: %s"(validationError.value);
|
||||
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(validationError.value);
|
||||
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(
|
||||
"DELETE FROM classroom_compliance_desk_assignment WHERE class_id = ?",
|
||||
cls.id
|
||||
"INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
cls.id,
|
||||
payload.studentId,
|
||||
payload.date,
|
||||
getUnixTimestampMillis(),
|
||||
payload.absent
|
||||
);
|
||||
auto stmt = db.prepare(
|
||||
"INSERT INTO classroom_compliance_desk_assignment (class_id, desk_number, student_id) VALUES (?, ?, ?)"
|
||||
);
|
||||
foreach (entry; payload.entries) {
|
||||
stmt.bindAll(cls.id, entry.deskNumber, entry.studentId);
|
||||
stmt.execute();
|
||||
stmt.clearBindings();
|
||||
stmt.reset();
|
||||
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();
|
||||
// Return the new list of desk assignments to the user.
|
||||
auto newAssignments = findAll!(ClassroomComplianceDeskAssignment)(
|
||||
db,
|
||||
"SELECT * FROM classroom_compliance_desk_assignment WHERE class_id = ?",
|
||||
cls.id
|
||||
);
|
||||
writeJsonBody(ctx, newAssignments);
|
||||
} catch (Exception e) {
|
||||
db.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
Optional!string validateDeskAssignments(Database db, in DeskAssignmentPayload payload, ulong classId) {
|
||||
import std.algorithm : canFind, map;
|
||||
import std.array;
|
||||
// Check that desks are numbered 1 .. N.
|
||||
for (int n = 1; n <= payload.entries.length; n++) {
|
||||
bool deskPresent = false;
|
||||
foreach (entry; payload.entries) {
|
||||
if (entry.deskNumber == n) {
|
||||
deskPresent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!deskPresent) return Optional!string.of("Desks should be numbered from 1 to N.");
|
||||
}
|
||||
auto allStudents = findAll!(ClassroomComplianceStudent)(
|
||||
db,
|
||||
"SELECT * FROM classroom_compliance_student WHERE class_id = ?",
|
||||
classId
|
||||
);
|
||||
auto studentIds = allStudents.map!(s => s.id).array;
|
||||
// Check that if a desk is assigned to a student, that it's one from this class.
|
||||
foreach (entry; payload.entries) {
|
||||
if (!entry.studentId.isNull && !canFind(studentIds, entry.studentId.get)) {
|
||||
return Optional!string.of("Desks cannot be assigned to students that don't belong to this class.");
|
||||
}
|
||||
}
|
||||
|
||||
// Check that each student in the class is assigned a desk.
|
||||
ushort[] takenDesks;
|
||||
foreach (student; allStudents) {
|
||||
ushort[] assignedDesks;
|
||||
foreach (entry; payload.entries) {
|
||||
if (!entry.studentId.isNull && entry.studentId.get() == student.id) {
|
||||
assignedDesks ~= entry.deskNumber;
|
||||
}
|
||||
}
|
||||
if (assignedDesks.length != 1) {
|
||||
if (assignedDesks.length > 1) {
|
||||
return Optional!string.of("A student is assigned to more than one desk.");
|
||||
} else {
|
||||
return Optional!string.of("Not all students are assigned to a desk.");
|
||||
}
|
||||
}
|
||||
if (canFind(takenDesks, assignedDesks[0])) {
|
||||
return Optional!string.of("Cannot assign more than one student to the same desk.");
|
||||
}
|
||||
takenDesks ~= assignedDesks[0];
|
||||
}
|
||||
return Optional!string.empty();
|
||||
}
|
||||
|
||||
void getDeskAssignments(ref HttpRequestContext ctx) {
|
||||
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();
|
||||
auto deskAssignments = findAll!(ClassroomComplianceDeskAssignment)(
|
||||
db,
|
||||
"
|
||||
SELECT d.* FROM classroom_compliance_desk_assignment d
|
||||
WHERE class_id = ?
|
||||
ORDER BY desk_number ASC
|
||||
",
|
||||
cls.id
|
||||
);
|
||||
writeJsonBody(ctx, deskAssignments);
|
||||
}
|
||||
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];
|
||||
|
||||
void removeAllDeskAssignments(ref HttpRequestContext ctx) {
|
||||
User user = getUserOrThrow(ctx);
|
||||
auto cls = getClassOrThrow(ctx, user);
|
||||
auto db = getDb();
|
||||
db.execute(
|
||||
"DELETE FROM classroom_compliance_desk_assignment WHERE class_id = ?",
|
||||
cls.id
|
||||
);
|
||||
}
|
||||
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));
|
||||
|
||||
void createEntry(ref HttpRequestContext ctx) {
|
||||
User user = getUserOrThrow(ctx);
|
||||
auto cls = getClassOrThrow(ctx, user);
|
||||
|
||||
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");
|
||||
}
|
||||
|
|
|
@ -28,8 +28,8 @@ Database getDb() {
|
|||
db.run(authSchema);
|
||||
db.run(classroomComplianceSchema);
|
||||
|
||||
const string sampleData = import("schema/sample_data.sql");
|
||||
db.run(sampleData);
|
||||
import sample_data;
|
||||
insertSampleData(db);
|
||||
|
||||
info("Initialized database schema.");
|
||||
}
|
||||
|
@ -92,8 +92,8 @@ private string[] getColumnNames(T)() {
|
|||
}
|
||||
|
||||
private string getArgsStr(T)() {
|
||||
import std.traits : RepresentationTypeTuple;
|
||||
alias types = RepresentationTypeTuple!T;
|
||||
import std.traits : Fields;
|
||||
alias types = Fields!T;
|
||||
string argsStr = "";
|
||||
static foreach (i, type; types) {
|
||||
argsStr ~= "row.peek!(" ~ type.stringof ~ ")(" ~ i.to!string ~ ")";
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
module sample_data;
|
||||
|
||||
import db;
|
||||
import data_utils;
|
||||
import d2sqlite3;
|
||||
|
||||
import std.random;
|
||||
import std.algorithm;
|
||||
import std.array;
|
||||
import std.datetime;
|
||||
|
||||
private const STUDENT_NAMES = [
|
||||
"Andrew",
|
||||
"Richard",
|
||||
"Klaus",
|
||||
"John",
|
||||
"Wilson",
|
||||
"Grace",
|
||||
"Sarah",
|
||||
"Rebecca",
|
||||
"Lily",
|
||||
"Thomas",
|
||||
"Michael",
|
||||
"Jennifer",
|
||||
"Robert",
|
||||
"Christopher",
|
||||
"Margaret",
|
||||
"Mordecai",
|
||||
"Rigby",
|
||||
"Walter",
|
||||
"Roy",
|
||||
"Cindy"
|
||||
];
|
||||
|
||||
void insertSampleData(ref Database db) {
|
||||
db.begin();
|
||||
ulong adminUserId = addUser(db, "test", "test", false, true);
|
||||
ulong normalUserId = addUser(db, "test2", "test", false, false);
|
||||
Random rand = Random(0);
|
||||
const SysTime now = Clock.currTime();
|
||||
const Date today = Date(now.year, now.month, now.day);
|
||||
|
||||
for (ushort i = 1; i <= 6; i++) {
|
||||
ulong classId = addClass(db, "2024-2025", i, adminUserId);
|
||||
bool classHasAssignedDesks = uniform01(rand) < 0.5;
|
||||
size_t count = uniform(10, STUDENT_NAMES.length, rand);
|
||||
auto studentsToAdd = randomSample(STUDENT_NAMES, count, rand);
|
||||
ushort deskNumber = 1;
|
||||
foreach (name; studentsToAdd) {
|
||||
bool removed = uniform01(rand) < 0.1;
|
||||
ushort assignedDeskNumber = 0;
|
||||
if (classHasAssignedDesks) {
|
||||
assignedDeskNumber = deskNumber++;
|
||||
}
|
||||
ulong studentId = addStudent(db, name, classId, assignedDeskNumber, removed);
|
||||
|
||||
// Add entries for the last N days
|
||||
for (int n = 0; n < 30; n++) {
|
||||
Date entryDate = today - days(n);
|
||||
bool missingEntry = uniform01(rand) < 0.05;
|
||||
if (missingEntry) continue;
|
||||
|
||||
bool absent = uniform01(rand) < 0.05;
|
||||
bool phoneCompliant = uniform01(rand) < 0.85;
|
||||
ubyte behaviorRating = 3;
|
||||
string behaviorComment = null;
|
||||
if (uniform01(rand) < 0.25) {
|
||||
behaviorRating = 2;
|
||||
behaviorComment = "They did not participate enough.";
|
||||
if (uniform01(rand) < 0.5) {
|
||||
behaviorRating = 3;
|
||||
behaviorComment = "They are a horrible student.";
|
||||
}
|
||||
}
|
||||
addEntry(db, classId, studentId, entryDate, absent, phoneCompliant, behaviorRating, behaviorComment);
|
||||
}
|
||||
}
|
||||
}
|
||||
db.commit();
|
||||
}
|
||||
|
||||
ulong addUser(ref Database db, string username, string password, bool locked, bool admin) {
|
||||
const query = "INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES (?, ?, ?, ?, ?)";
|
||||
import std.digest.sha;
|
||||
import std.stdio;
|
||||
string passwordHash = cast(string) sha256Of(password).toHexString().idup;
|
||||
db.execute(query, username, passwordHash, getUnixTimestampMillis(), locked, admin);
|
||||
return db.lastInsertRowid();
|
||||
}
|
||||
|
||||
ulong addClass(ref Database db, string schoolYear, ushort number, ulong userId) {
|
||||
const query = "INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?)";
|
||||
db.execute(query, number, schoolYear, userId);
|
||||
return db.lastInsertRowid();
|
||||
}
|
||||
|
||||
ulong addStudent(ref Database db, string name, ulong classId, ushort deskNumber, bool removed) {
|
||||
const query = "INSERT INTO classroom_compliance_student (name, class_id, desk_number, removed) VALUES (?, ?, ?, ?)";
|
||||
db.execute(query, name, classId, deskNumber, removed);
|
||||
return db.lastInsertRowid();
|
||||
}
|
||||
|
||||
void addEntry(
|
||||
ref Database db,
|
||||
ulong classId,
|
||||
ulong studentId,
|
||||
Date date,
|
||||
bool absent,
|
||||
bool phoneCompliant,
|
||||
ubyte behaviorRating,
|
||||
string behaviorComment
|
||||
) {
|
||||
const entryQuery = "
|
||||
INSERT INTO classroom_compliance_entry
|
||||
(class_id, student_id, date, created_at, absent)
|
||||
VALUES (?, ?, ?, ?, ?)";
|
||||
db.execute(entryQuery, classId, studentId, date.toISOExtString(), getUnixTimestampMillis(), absent);
|
||||
if (absent) return;
|
||||
ulong entryId = db.lastInsertRowid();
|
||||
const phoneQuery = "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)";
|
||||
db.execute(phoneQuery, entryId, phoneCompliant);
|
||||
const behaviorQuery = "
|
||||
INSERT INTO classroom_compliance_entry_behavior
|
||||
(entry_id, rating, comment)
|
||||
VALUES (?, ?, ?)";
|
||||
db.execute(behaviorQuery, entryId, behaviorRating, behaviorComment);
|
||||
}
|
|
@ -8,6 +8,49 @@ export interface Class {
|
|||
schoolYear: string
|
||||
}
|
||||
|
||||
export interface Student {
|
||||
id: number
|
||||
name: string
|
||||
classId: number
|
||||
deskNumber: number
|
||||
removed: boolean
|
||||
}
|
||||
|
||||
export interface EntryResponseItemPhone {
|
||||
compliant: boolean
|
||||
}
|
||||
|
||||
export interface EntryResponseItemBehavior {
|
||||
rating: number
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface EntryResponseItem {
|
||||
id: number
|
||||
date: string
|
||||
createdAt: string
|
||||
absent: boolean
|
||||
phone?: EntryResponseItemPhone
|
||||
behavior?: EntryResponseItemBehavior
|
||||
}
|
||||
|
||||
export interface EntryResponseStudent {
|
||||
id: number
|
||||
name: string
|
||||
deskNumber: number
|
||||
removed: boolean
|
||||
entries: Record<string, EntryResponseItem | null>
|
||||
}
|
||||
|
||||
export interface EntriesResponse {
|
||||
students: EntryResponseStudent[]
|
||||
dates: string[]
|
||||
}
|
||||
|
||||
function getClassPath(classId: number): string {
|
||||
return BASE_URL + '/classes/' + classId
|
||||
}
|
||||
|
||||
export async function createClass(
|
||||
auth: string,
|
||||
number: number,
|
||||
|
@ -28,8 +71,15 @@ export async function getClasses(auth: string): Promise<Class[]> {
|
|||
return (await response.json()) as Class[]
|
||||
}
|
||||
|
||||
export async function getClass(auth: string, id: number): Promise<Class> {
|
||||
const response = await fetch(getClassPath(id), {
|
||||
headers: getAuthHeaders(auth),
|
||||
})
|
||||
return (await response.json()) as Class
|
||||
}
|
||||
|
||||
export async function deleteClass(auth: string, classId: number): Promise<void> {
|
||||
const response = await fetch(BASE_URL + '/classes/' + classId, {
|
||||
const response = await fetch(getClassPath(classId), {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(auth),
|
||||
})
|
||||
|
@ -37,3 +87,29 @@ export async function deleteClass(auth: string, classId: number): Promise<void>
|
|||
throw new Error('Failed to delete class.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudents(auth: string, classId: number): Promise<Student[]> {
|
||||
const response = await fetch(getClassPath(classId) + '/students', {
|
||||
headers: getAuthHeaders(auth),
|
||||
})
|
||||
return (await response.json()) as Student[]
|
||||
}
|
||||
|
||||
export async function getEntries(
|
||||
auth: string,
|
||||
classId: number,
|
||||
fromDate?: Date,
|
||||
toDate?: Date,
|
||||
): Promise<EntriesResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (fromDate) {
|
||||
params.append('from', fromDate.toISOString().substring(0, 10))
|
||||
}
|
||||
if (toDate) {
|
||||
params.append('to', toDate.toISOString().substring(0, 10))
|
||||
}
|
||||
const response = await fetch(getClassPath(classId) + '/entries?' + params.toString(), {
|
||||
headers: getAuthHeaders(auth),
|
||||
})
|
||||
return (await response.json()) as EntriesResponse
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { type Class } from '@/api/classroom_compliance';
|
||||
|
||||
defineProps<{
|
||||
cls: Class
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="class-item">
|
||||
<h3>Class <span v-text="cls.number"></span></h3>
|
||||
<p v-text="cls.schoolYear"></p>
|
||||
<div>
|
||||
<RouterLink :to="'/classroom-compliance/classes/' + cls.id">View</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.class-item {
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { getClass, getStudents, type Class, type Student } from '@/api/classroom_compliance';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
const authStore = useAuthStore()
|
||||
const cls: Ref<Class | null> = ref(null)
|
||||
const students: Ref<Student[]> = ref([])
|
||||
onMounted(async () => {
|
||||
const idNumber = parseInt(props.id, 10)
|
||||
cls.value = await getClass(authStore.getBasicAuth(), idNumber)
|
||||
getStudents(authStore.getBasicAuth(), idNumber).then(r => {
|
||||
students.value = r
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="cls">
|
||||
<h1>Class #<span v-text="cls.number"></span></h1>
|
||||
<p>ID: <span v-text="cls.id"></span></p>
|
||||
<p>School Year: <span v-text="cls.schoolYear"></span></p>
|
||||
<div>
|
||||
<div>
|
||||
<span>Actions: </span>
|
||||
<button type="button">Add Student - WIP</button>
|
||||
<button type="button">Delete this Class</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EntriesTable :classId="cls.id" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped></style>
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { type Class, getClasses } from '@/api/classroom_compliance';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { type Ref, ref, onMounted } from 'vue';
|
||||
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue';
|
||||
|
||||
const classes: Ref<Class[]> = ref([])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
classes.value = await getClasses(authStore.getBasicAuth())
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
||||
</template>
|
|
@ -0,0 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
import { getEntries, type EntryResponseStudent } from '@/api/classroom_compliance';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import EntryTableCell from './EntryTableCell.vue';
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const props = defineProps<{
|
||||
classId: number
|
||||
}>()
|
||||
|
||||
const students: Ref<EntryResponseStudent[]> = ref([])
|
||||
const dates: Ref<string[]> = ref([])
|
||||
const toDate: Ref<Date> = ref(new Date())
|
||||
const fromDate: Ref<Date> = ref(new Date())
|
||||
onMounted(async () => {
|
||||
toDate.value.setHours(0, 0, 0, 0)
|
||||
fromDate.value.setHours(0, 0, 0, 0)
|
||||
fromDate.value.setDate(fromDate.value.getDate() - 4)
|
||||
await loadEntries()
|
||||
})
|
||||
|
||||
async function loadEntries() {
|
||||
const entries = await getEntries(
|
||||
authStore.getBasicAuth(),
|
||||
props.classId,
|
||||
fromDate.value,
|
||||
toDate.value
|
||||
)
|
||||
students.value = entries.students
|
||||
dates.value = entries.dates
|
||||
}
|
||||
|
||||
function shiftDateRange(days: number) {
|
||||
toDate.value.setDate(toDate.value.getDate() + days)
|
||||
fromDate.value.setDate(fromDate.value.getDate() + days)
|
||||
}
|
||||
|
||||
async function showPreviousDay() {
|
||||
shiftDateRange(-1)
|
||||
await loadEntries()
|
||||
}
|
||||
|
||||
async function showNextDay() {
|
||||
shiftDateRange(1)
|
||||
await loadEntries()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<button type="button" @click="showPreviousDay">Previous Day</button>
|
||||
<button type="button" @click="showNextDay">Next Day</button>
|
||||
</div>
|
||||
<table class="entries-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Desk</th>
|
||||
<th v-for="date in dates" :key="date" v-text="date"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="student in students" :key="student.id">
|
||||
<td v-text="student.name" :class="{ 'student-removed': student.removed }"></td>
|
||||
<td v-text="student.deskNumber"></td>
|
||||
<EntryTableCell v-for="(entry, date) in student.entries" :key="date" :entry="entry" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.entries-table {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entries-table,
|
||||
.entries-table th,
|
||||
.entries-table td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.student-removed {
|
||||
background-color: lightgray;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,46 @@
|
|||
<script setup lang="ts">
|
||||
import type { EntryResponseItem } from '@/api/classroom_compliance';
|
||||
|
||||
defineProps<{
|
||||
entry: EntryResponseItem | null
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<td v-if="entry" :class="{ absent: entry.absent }">
|
||||
<span v-if="entry.absent">Absent</span>
|
||||
<div v-if="!entry.absent">
|
||||
<div class="status-item">
|
||||
<span v-if="entry.phone?.compliant">📱</span>
|
||||
<span v-if="!entry.phone?.compliant">📵</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span v-if="entry.behavior?.rating === 3">😇</span>
|
||||
<span v-if="entry.behavior?.rating === 2">😐</span>
|
||||
<span v-if="entry.behavior?.rating === 1">😡</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="entry === null" class="missing-entry"></td>
|
||||
</template>
|
||||
<style scoped>
|
||||
td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.missing-entry {
|
||||
background-color: lightgray;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.absent {
|
||||
color: blue;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<h1>Classroom Compliance</h1>
|
||||
<p>With this application, you can track each student's compliance to various classroom policies, like:</p>
|
||||
<ul>
|
||||
<li>Attendance</li>
|
||||
<li>Phone Usage (or lack thereof)</li>
|
||||
<li>Behavior</li>
|
||||
</ul>
|
||||
<hr>
|
||||
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
|
@ -15,7 +15,18 @@ const router = createRouter({
|
|||
},
|
||||
{
|
||||
path: '/classroom-compliance',
|
||||
component: () => import('@/views/apps/ClassroomCompliance.vue'),
|
||||
component: () => import('@/apps/classroom_compliance/MainView.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/apps/classroom_compliance/ClassesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'classes/:id',
|
||||
component: () => import('@/apps/classroom_compliance/ClassView.vue'),
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { getClasses, type Class } from '@/api/classroom_compliance';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
|
||||
const classes: Ref<Class[]> = ref([])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
classes.value = await getClasses(authStore.getBasicAuth())
|
||||
console.log(classes.value)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<h1>Classroom Compliance</h1>
|
||||
<p>Here you can track stuff.</p>
|
||||
<div v-for="cls in classes" :key="cls.id">
|
||||
<h3 v-text="cls.number"></h3>
|
||||
<p v-text="cls.schoolYear"></p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
|
@ -0,0 +1,15 @@
|
|||
meta {
|
||||
name: Get Compliance Entries
|
||||
type: http
|
||||
seq: 9
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/classroom-compliance/classes/:classId/entries
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
params:path {
|
||||
classId: {{class_id}}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
meta {
|
||||
name: Get Desk Assignments
|
||||
type: http
|
||||
seq: 8
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/classroom-compliance/classes/:classId/desk-assignments
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:path {
|
||||
classId: {{class_id}}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
meta {
|
||||
name: Set Desk Assignments
|
||||
type: http
|
||||
seq: 9
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/classroom-compliance/classes/:classId/desk-assignments
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:path {
|
||||
classId: {{class_id}}
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"deskNumber": 1,
|
||||
"studentId": 1
|
||||
},
|
||||
{
|
||||
"deskNumber": 2,
|
||||
"studentId": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue