Added entries table, and entries endpoint.

This commit is contained in:
Andrew Lalis 2024-12-20 19:27:08 -05:00
parent e7683a5c9d
commit 7166b995f7
17 changed files with 673 additions and 226 deletions

View File

@ -9,24 +9,16 @@ CREATE TABLE classroom_compliance_class (
CREATE TABLE classroom_compliance_student ( CREATE TABLE classroom_compliance_student (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, 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) class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
desk_number INTEGER NOT NULL, desk_number INTEGER NOT NULL DEFAULT 0,
student_id INTEGER REFERENCES classroom_compliance_student(id) removed INTEGER NOT NULL DEFAULT 0
ON UPDATE CASCADE ON DELETE SET NULL
); );
CREATE TABLE classroom_compliance_entry ( CREATE TABLE classroom_compliance_entry (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id) class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
class_description TEXT NOT NULL,
student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id), student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id),
date TEXT NOT NULL, date TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,

View File

@ -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
);

View File

@ -2,8 +2,10 @@ module api_modules.classroom_compliance;
import handy_httpd; import handy_httpd;
import handy_httpd.handlers.path_handler; import handy_httpd.handlers.path_handler;
import std.typecons : Nullable;
import d2sqlite3; import d2sqlite3;
import slf4d;
import std.typecons : Nullable;
import std.datetime;
import db; import db;
import data_utils; import data_utils;
@ -20,19 +22,13 @@ struct ClassroomComplianceStudent {
const ulong id; const ulong id;
const string name; const string name;
const ulong classId; const ulong classId;
}
struct ClassroomComplianceDeskAssignment {
const ulong id;
const ulong classId;
const ushort deskNumber; const ushort deskNumber;
const ulong studentId; const bool removed;
} }
struct ClassroomComplianceEntry { struct ClassroomComplianceEntry {
const ulong id; const ulong id;
const ulong classId; const ulong classId;
const string classDescription;
const ulong studentId; const ulong studentId;
const string date; const string date;
const ulong createdAt; const ulong createdAt;
@ -56,6 +52,7 @@ void registerApiEndpoints(PathHandler handler) {
handler.addMapping(Method.POST, ROOT_PATH ~ "/classes", &createClass); handler.addMapping(Method.POST, ROOT_PATH ~ "/classes", &createClass);
handler.addMapping(Method.GET, ROOT_PATH ~ "/classes", &getClasses); handler.addMapping(Method.GET, ROOT_PATH ~ "/classes", &getClasses);
const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong"; 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.DELETE, CLASS_PATH, &deleteClass);
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent); 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.PUT, STUDENT_PATH, &updateStudent);
handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent); 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.POST, CLASS_PATH ~ "/entries", &createEntry);
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
} }
void createClass(ref HttpRequestContext ctx) { void createClass(ref HttpRequestContext ctx) {
@ -115,6 +109,12 @@ void getClasses(ref HttpRequestContext ctx) {
writeJsonBody(ctx, classes); 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) { void deleteClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user); auto cls = getClassOrThrow(ctx, user);
@ -138,6 +138,7 @@ void createStudent(ref HttpRequestContext ctx) {
auto cls = getClassOrThrow(ctx, user); auto cls = getClassOrThrow(ctx, user);
struct StudentPayload { struct StudentPayload {
string name; string name;
ushort deskNumber;
} }
auto payload = readJsonPayload!(StudentPayload)(ctx); auto payload = readJsonPayload!(StudentPayload)(ctx);
auto db = getDb(); auto db = getDb();
@ -152,7 +153,20 @@ void createStudent(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("Student with that name already exists."); ctx.response.writeBodyString("Student with that name already exists.");
return; 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(); ulong studentId = db.lastInsertRowid();
auto student = findOne!(ClassroomComplianceStudent)( auto student = findOne!(ClassroomComplianceStudent)(
db, db,
@ -179,10 +193,14 @@ void updateStudent(ref HttpRequestContext ctx) {
auto student = getStudentOrThrow(ctx, user); auto student = getStudentOrThrow(ctx, user);
struct StudentUpdatePayload { struct StudentUpdatePayload {
string name; string name;
ushort deskNumber;
} }
auto payload = readJsonPayload!(StudentUpdatePayload)(ctx); auto payload = readJsonPayload!(StudentUpdatePayload)(ctx);
// If there is nothing to update, quit. // 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. // Check that the new name doesn't already exist.
auto db = getDb(); auto db = getDb();
bool newNameExists = canFind( bool newNameExists = canFind(
@ -196,9 +214,22 @@ void updateStudent(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("Student with that name already exists."); ctx.response.writeBodyString("Student with that name already exists.");
return; 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( db.execute(
"UPDATE classroom_compliance_student SET name = ? WHERE id = ?", "UPDATE classroom_compliance_student SET name = ?, desk_number = ? WHERE id = ?",
payload.name, payload.name,
payload.deskNumber,
student.id student.id
); );
auto updatedStudent = findOne!(ClassroomComplianceStudent)( auto updatedStudent = findOne!(ClassroomComplianceStudent)(
@ -239,135 +270,187 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User
).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); ).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
} }
private struct DeskAssignmentPayloadEntry { void createEntry(ref HttpRequestContext ctx) {
ushort deskNumber;
Nullable!ulong studentId;
}
private struct DeskAssignmentPayload {
DeskAssignmentPayloadEntry[] entries;
}
void setDeskAssignments(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user); 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 db = getDb();
auto validationError = validateDeskAssignments(db, payload, cls.id); bool entryAlreadyExists = canFind(
if (validationError) { db,
import slf4d; "SELECT id FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
warnF!"Desk assignment validation failed: %s"(validationError.value); cls.id,
payload.studentId,
payload.date
);
if (entryAlreadyExists) {
ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString(validationError.value); ctx.response.writeBodyString("An entry already exists for this student and date.");
return; return;
} }
// Insert the entry and its attached entities in a transaction.
db.begin(); db.begin();
try { try {
db.execute( db.execute(
"DELETE FROM classroom_compliance_desk_assignment WHERE class_id = ?", "INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent)
cls.id VALUES (?, ?, ?, ?, ?)",
cls.id,
payload.studentId,
payload.date,
getUnixTimestampMillis(),
payload.absent
); );
auto stmt = db.prepare( ulong entryId = db.lastInsertRowid();
"INSERT INTO classroom_compliance_desk_assignment (class_id, desk_number, student_id) VALUES (?, ?, ?)" 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
); );
foreach (entry; payload.entries) {
stmt.bindAll(cls.id, entry.deskNumber, entry.studentId);
stmt.execute();
stmt.clearBindings();
stmt.reset();
} }
db.commit(); 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) { } catch (Exception e) {
db.rollback(); db.rollback();
} }
} }
Optional!string validateDeskAssignments(Database db, in DeskAssignmentPayload payload, ulong classId) { void getEntries(ref HttpRequestContext ctx) {
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) {
User user = getUserOrThrow(ctx); User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user); 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 db = getDb();
auto deskAssignments = findAll!(ClassroomComplianceDeskAssignment)( const query = "
db, SELECT
" entry.id,
SELECT d.* FROM classroom_compliance_desk_assignment d entry.date,
WHERE class_id = ? entry.created_at,
ORDER BY desk_number ASC entry.absent,
", student.id,
cls.id student.name,
); student.desk_number,
writeJsonBody(ctx, deskAssignments); 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) { JSONValue entry = JSONValue.emptyObject;
User user = getUserOrThrow(ctx); entry.object["id"] = JSONValue(row.peek!ulong(0));
auto cls = getClassOrThrow(ctx, user); entry.object["date"] = JSONValue(row.peek!string(1));
auto db = getDb(); entry.object["createdAt"] = JSONValue(row.peek!string(2));
db.execute( entry.object["absent"] = JSONValue(row.peek!bool(3));
"DELETE FROM classroom_compliance_desk_assignment WHERE class_id = ?",
cls.id 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;
void createEntry(ref HttpRequestContext ctx) { string dateStr = entry.object["date"].str();
User user = getUserOrThrow(ctx); studentObj.object["entries"].object[dateStr] = entry;
auto cls = getClassOrThrow(ctx, user); }
// 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");
} }

View File

@ -28,8 +28,8 @@ Database getDb() {
db.run(authSchema); db.run(authSchema);
db.run(classroomComplianceSchema); db.run(classroomComplianceSchema);
const string sampleData = import("schema/sample_data.sql"); import sample_data;
db.run(sampleData); insertSampleData(db);
info("Initialized database schema."); info("Initialized database schema.");
} }
@ -92,8 +92,8 @@ private string[] getColumnNames(T)() {
} }
private string getArgsStr(T)() { private string getArgsStr(T)() {
import std.traits : RepresentationTypeTuple; import std.traits : Fields;
alias types = RepresentationTypeTuple!T; alias types = Fields!T;
string argsStr = ""; string argsStr = "";
static foreach (i, type; types) { static foreach (i, type; types) {
argsStr ~= "row.peek!(" ~ type.stringof ~ ")(" ~ i.to!string ~ ")"; argsStr ~= "row.peek!(" ~ type.stringof ~ ")(" ~ i.to!string ~ ")";

127
api/source/sample_data.d Normal file
View File

@ -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);
}

View File

@ -8,6 +8,49 @@ export interface Class {
schoolYear: string 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( export async function createClass(
auth: string, auth: string,
number: number, number: number,
@ -28,8 +71,15 @@ export async function getClasses(auth: string): Promise<Class[]> {
return (await response.json()) as 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> { 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', method: 'DELETE',
headers: getAuthHeaders(auth), headers: getAuthHeaders(auth),
}) })
@ -37,3 +87,29 @@ export async function deleteClass(auth: string, classId: number): Promise<void>
throw new Error('Failed to delete class.') 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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -15,7 +15,18 @@ const router = createRouter({
}, },
{ {
path: '/classroom-compliance', 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,
},
],
}, },
], ],
}) })

View File

@ -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>

View File

@ -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}}
}

View File

@ -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}}
}

View File

@ -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
}
]
}
}