diff --git a/api/schema/classroom_compliance.sql b/api/schema/classroom_compliance.sql
index 8e8be60..b1486c6 100644
--- a/api/schema/classroom_compliance.sql
+++ b/api/schema/classroom_compliance.sql
@@ -19,7 +19,8 @@ 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,
- student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id),
+ student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id)
+ ON UPDATE CASCADE ON DELETE CASCADE,
date TEXT NOT NULL,
created_at INTEGER NOT NULL,
absent INTEGER NOT NULL DEFAULT 0
diff --git a/api/source/api_modules/classroom_compliance.d b/api/source/api_modules/classroom_compliance.d
index 733d8cd..32f0d0c 100644
--- a/api/source/api_modules/classroom_compliance.d
+++ b/api/source/api_modules/classroom_compliance.d
@@ -6,6 +6,10 @@ import d2sqlite3;
import slf4d;
import std.typecons : Nullable;
import std.datetime;
+import std.format;
+import std.json;
+import std.algorithm;
+import std.array;
import db;
import data_utils;
@@ -58,11 +62,12 @@ void registerApiEndpoints(PathHandler handler) {
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.POST, CLASS_PATH ~ "/entries", &createEntry);
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
+ handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries);
}
void createClass(ref HttpRequestContext ctx) {
@@ -82,7 +87,7 @@ void createClass(ref HttpRequestContext ctx) {
);
if (classNumberExists) {
ctx.response.status = HttpStatus.BAD_REQUEST;
- ctx.response.writeBodyString("There is already a class with this number, for this school year.");
+ 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 (?, ?, ?)");
@@ -139,6 +144,7 @@ void createStudent(ref HttpRequestContext ctx) {
struct StudentPayload {
string name;
ushort deskNumber;
+ bool removed;
}
auto payload = readJsonPayload!(StudentPayload)(ctx);
auto db = getDb();
@@ -150,7 +156,7 @@ void createStudent(ref HttpRequestContext ctx) {
);
if (studentExists) {
ctx.response.status = HttpStatus.BAD_REQUEST;
- ctx.response.writeBodyString("Student with that name already exists.");
+ ctx.response.writeBodyString("A student with that name already exists in this class.");
return;
}
bool deskAlreadyOccupied = payload.deskNumber != 0 && canFind(
@@ -161,11 +167,11 @@ void createStudent(ref HttpRequestContext ctx) {
);
if (deskAlreadyOccupied) {
ctx.response.status = HttpStatus.BAD_REQUEST;
- ctx.response.writeBodyString("There is already a student assigned to that desk.");
+ 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) VALUES (?, ?)",
- payload.name, cls.id, payload.deskNumber
+ "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)(
@@ -188,22 +194,30 @@ void getStudents(ref HttpRequestContext ctx) {
writeJsonBody(ctx, students);
}
+void getStudent(ref HttpRequestContext ctx) {
+ User user = getUserOrThrow(ctx);
+ auto student = getStudentOrThrow(ctx, user);
+ writeJsonBody(ctx, student);
+}
+
void updateStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto student = getStudentOrThrow(ctx, 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.
auto db = getDb();
- bool newNameExists = canFind(
+ bool newNameExists = payload.name != student.name && canFind(
db,
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
payload.name,
@@ -211,11 +225,11 @@ void updateStudent(ref HttpRequestContext ctx) {
);
if (newNameExists) {
ctx.response.status = HttpStatus.BAD_REQUEST;
- ctx.response.writeBodyString("Student with that name already exists.");
+ 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 && canFind(
+ 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,
@@ -227,9 +241,10 @@ void updateStudent(ref HttpRequestContext ctx) {
return;
}
db.execute(
- "UPDATE classroom_compliance_student SET name = ?, desk_number = ? WHERE id = ?",
+ "UPDATE classroom_compliance_student SET name = ?, desk_number = ?, removed = ? WHERE id = ?",
payload.name,
payload.deskNumber,
+ payload.removed,
student.id
);
auto updatedStudent = findOne!(ClassroomComplianceStudent)(
@@ -270,75 +285,6 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User
).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}
-void createEntry(ref HttpRequestContext ctx) {
- User user = getUserOrThrow(ctx);
- auto cls = getClassOrThrow(ctx, user);
- struct EntryPhonePayload {
- bool compliant;
- }
- struct EntryBehaviorPayload {
- int rating;
- Nullable!string comment;
- }
- struct EntryPayload {
- ulong studentId;
- string date;
- bool absent;
- Nullable!EntryPhonePayload phoneCompliance;
- Nullable!EntryBehaviorPayload behaviorCompliance;
- }
- auto payload = readJsonPayload!(EntryPayload)(ctx);
- auto db = getDb();
- bool entryAlreadyExists = canFind(
- db,
- "SELECT id FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
- cls.id,
- payload.studentId,
- payload.date
- );
- if (entryAlreadyExists) {
- ctx.response.status = HttpStatus.BAD_REQUEST;
- ctx.response.writeBodyString("An entry already exists for this student and date.");
- return;
- }
- // Insert the entry and its attached entities in a transaction.
- db.begin();
- try {
- db.execute(
- "INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent)
- VALUES (?, ?, ?, ?, ?)",
- cls.id,
- payload.studentId,
- payload.date,
- getUnixTimestampMillis(),
- payload.absent
- );
- ulong entryId = db.lastInsertRowid();
- if (!payload.absent && !payload.phoneCompliance.isNull) {
- db.execute(
- "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)",
- entryId,
- payload.phoneCompliance.get().compliant
- );
- }
- if (!payload.absent && !payload.behaviorCompliance.isNull) {
- Nullable!string comment = payload.behaviorCompliance.get().comment;
- if (!comment.isNull && (comment.get() is null || comment.get().length == 0)) {
- comment.nullify();
- }
- db.execute(
- "INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment) VALUES (?, ?, ?)",
- entryId,
- payload.behaviorCompliance.get().rating,
- comment
- );
- }
- db.commit();
- } catch (Exception e) {
- db.rollback();
- }
-}
-
void getEntries(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
@@ -367,6 +313,22 @@ void getEntries(ref HttpRequestContext ctx) {
infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
auto db = getDb();
+
+ 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;
+ return obj;
+ }).array;
+
const query = "
SELECT
entry.id,
@@ -392,32 +354,16 @@ void getEntries(ref HttpRequestContext ctx) {
AND entry.date >= ?
AND entry.date <= ?
ORDER BY
- entry.date ASC,
- student.desk_number ASC,
- student.name ASC
+ student.id ASC,
+ entry.date ASC
";
ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
// Serialize the results into a custom-formatted response object.
- import std.json;
- JSONValue response = JSONValue.emptyObject;
- JSONValue[ulong] studentObjects;
foreach (row; result) {
- ulong studentId = row.peek!ulong(4);
- if (studentId !in studentObjects) {
- JSONValue student = JSONValue.emptyObject;
- student.object["id"] = JSONValue(row.peek!ulong(4));
- student.object["name"] = JSONValue(row.peek!string(5));
- student.object["deskNumber"] = JSONValue(row.peek!ushort(6));
- student.object["removed"] = JSONValue(row.peek!bool(7));
- student.object["entries"] = JSONValue.emptyObject;
- studentObjects[studentId] = student;
- }
- JSONValue studentObj = studentObjects[studentId];
-
JSONValue entry = JSONValue.emptyObject;
entry.object["id"] = JSONValue(row.peek!ulong(0));
entry.object["date"] = JSONValue(row.peek!string(1));
- entry.object["createdAt"] = JSONValue(row.peek!string(2));
+ entry.object["createdAt"] = JSONValue(row.peek!ulong(2));
entry.object["absent"] = JSONValue(row.peek!bool(3));
JSONValue phone = JSONValue(null);
@@ -431,12 +377,27 @@ void getEntries(ref HttpRequestContext ctx) {
}
entry.object["phone"] = phone;
entry.object["behavior"] = behavior;
-
string dateStr = entry.object["date"].str();
- studentObj.object["entries"].object[dateStr] = entry;
+
+ // 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.");
+ }
}
+
+ 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) {
@@ -449,8 +410,180 @@ void getEntries(ref HttpRequestContext ctx) {
}
d += days(1);
}
- response.object["students"] = JSONValue(studentObjects.values);
+ response.object["students"] = JSONValue(studentObjects);
string jsonStr = response.toJSON();
ctx.response.writeBodyString(jsonStr, "application/json");
}
+
+void saveEntries(ref HttpRequestContext ctx) {
+ User user = getUserOrThrow(ctx);
+ auto cls = getClassOrThrow(ctx, user);
+ JSONValue bodyContent = ctx.request.readBodyAsJson();
+ auto db = getDb();
+ db.begin();
+ 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) {
+ db.execute(
+ "DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
+ cls.id,
+ studentId,
+ dateStr
+ );
+ infoF!"Deleted entry for student %s on %s"(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(
+ format!"Cannot create a new entry for student %d on %s when one already exists."(
+ studentId,
+ dateStr
+ )
+ );
+ return;
+ }
+
+ insertNewEntry(db, cls.id, studentId, dateStr, entry);
+ } else {
+ if (existingEntry.isNull) {
+ ctx.response.status = HttpStatus.BAD_REQUEST;
+ ctx.response.writeBodyString(
+ format!"Cannot update entry %d because it doesn't exist."(
+ entryId
+ )
+ );
+ return;
+ }
+ updateEntry(db, cls.id, studentId, dateStr, entryId, entry);
+ }
+ }
+ }
+ db.commit();
+}
+
+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, comment)
+ 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);
+}
diff --git a/app/src/App.vue b/app/src/App.vue
index 828fb3d..fb64af3 100644
--- a/app/src/App.vue
+++ b/app/src/App.vue
@@ -1,8 +1,15 @@
@@ -15,7 +22,7 @@ const authStore = useAuthStore()
Welcome,
-
+
diff --git a/app/src/alerts.ts b/app/src/alerts.ts
new file mode 100644
index 0000000..46b0502
--- /dev/null
+++ b/app/src/alerts.ts
@@ -0,0 +1,11 @@
+export function showAlert(msg: string): Promise {
+ const dialog = document.getElementById('alert-dialog') as HTMLDialogElement
+ const messageBox = document.getElementById('alert-dialog-message') as HTMLParagraphElement
+ const closeButton = document.getElementById('alert-dialog-close-button') as HTMLButtonElement
+ closeButton.addEventListener('click', () => dialog.close())
+ messageBox.innerText = msg
+ dialog.showModal()
+ return new Promise((resolve) => {
+ dialog.addEventListener('close', () => resolve())
+ })
+}
diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts
index 3bab7de..d11eca6 100644
--- a/app/src/api/auth.ts
+++ b/app/src/api/auth.ts
@@ -1,3 +1,5 @@
+import { APIClient, APIResponse, type AuthStoreType } from './base'
+
const BASE_URL = import.meta.env.VITE_API_URL + '/auth'
export interface User {
@@ -8,22 +10,18 @@ export interface User {
isAdmin: boolean
}
-export function getAuthHeaders(basicAuth: string) {
- return {
- Authorization: 'Basic ' + basicAuth,
+export class AuthenticationAPIClient extends APIClient {
+ constructor(authStore: AuthStoreType) {
+ super(BASE_URL, authStore)
}
-}
-export async function login(username: string, password: string): Promise {
- const basicAuth = btoa(username + ':' + password)
- const response = await fetch(BASE_URL + '/login', {
- method: 'POST',
- headers: {
- Authorization: 'Basic ' + basicAuth,
- },
- })
- if (!response.ok) {
- return null
+ login(username: string, password: string): APIResponse {
+ const promise = fetch(this.baseUrl + '/login', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Basic ' + btoa(username + ':' + password),
+ },
+ })
+ return new APIResponse(this.handleAPIResponse(promise))
}
- return (await response.json()) as User
}
diff --git a/app/src/api/base.ts b/app/src/api/base.ts
new file mode 100644
index 0000000..f94dd28
--- /dev/null
+++ b/app/src/api/base.ts
@@ -0,0 +1,134 @@
+import { showAlert } from '@/alerts'
+import { useAuthStore } from '@/stores/auth'
+
+export abstract class APIError {
+ message: string
+ constructor(message: string) {
+ this.message = message
+ }
+}
+
+export class BadRequestError extends APIError {}
+
+export class InternalServerError extends APIError {}
+
+export class NetworkError extends APIError {}
+
+export class AuthenticationError extends APIError {}
+
+/**
+ * Represents a generic API response that that will be completed in the future
+ * using a given promise, which resolves either to a specified response type,
+ * or an API Error type.
+ */
+export class APIResponse {
+ result: Promise
+ constructor(result: Promise) {
+ this.result = result
+ }
+
+ async handleErrorsWithAlert(): Promise {
+ const value = await this.result
+ if (value instanceof APIError) {
+ await showAlert(value.message)
+ return null
+ }
+ return value
+ }
+
+ async getOrThrow(): Promise {
+ const value = await this.result
+ if (value instanceof APIError) throw value
+ return value
+ }
+}
+
+export type AuthStoreType = ReturnType
+
+export abstract class APIClient {
+ readonly baseUrl: string
+ authStore: AuthStoreType
+ constructor(baseUrl: string, authStore: AuthStoreType) {
+ this.baseUrl = baseUrl
+ this.authStore = authStore
+ }
+
+ protected get(url: string): APIResponse {
+ const promise = fetch(this.baseUrl + url, { headers: this.getAuthHeaders() })
+ return new APIResponse(this.handleAPIResponse(promise))
+ }
+
+ protected post(url: string, body: B): APIResponse {
+ const promise = fetch(this.baseUrl + url, {
+ headers: this.getAuthHeaders(),
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+ return new APIResponse(this.handleAPIResponse(promise))
+ }
+
+ protected postWithNoExpectedResponse(url: string, body: B): APIResponse {
+ const promise = fetch(this.baseUrl + url, {
+ headers: this.getAuthHeaders(),
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+ return new APIResponse(this.handleAPIResponseWithNoBody(promise))
+ }
+
+ protected put(url: string, body: B): APIResponse {
+ const promise = fetch(this.baseUrl + url, {
+ headers: this.getAuthHeaders(),
+ method: 'PUT',
+ body: JSON.stringify(body),
+ })
+ return new APIResponse(this.handleAPIResponse(promise))
+ }
+
+ protected delete(url: string): APIResponse {
+ const promise = fetch(this.baseUrl + url, {
+ headers: this.getAuthHeaders(),
+ method: 'DELETE',
+ })
+ return new APIResponse(this.handleAPIResponseWithNoBody(promise))
+ }
+
+ protected getAuthHeaders() {
+ return {
+ Authorization: 'Basic ' + this.authStore.getBasicAuth(),
+ }
+ }
+
+ protected async handleAPIResponse(promise: Promise): Promise {
+ try {
+ const response = await promise
+ if (response.ok) {
+ return (await response.json()) as T
+ }
+ if (response.status === 400) {
+ return new BadRequestError(await response.text())
+ }
+ if (response.status === 401) {
+ return new AuthenticationError(await response.text())
+ }
+ return new InternalServerError(await response.text())
+ } catch (error) {
+ return new NetworkError('' + error)
+ }
+ }
+
+ protected async handleAPIResponseWithNoBody(
+ promise: Promise,
+ ): Promise {
+ try {
+ const response = await promise
+ if (response.ok) return undefined
+ if (response.status === 401) {
+ return new AuthenticationError(await response.text())
+ }
+ return new InternalServerError(await response.text())
+ } catch (error) {
+ return new NetworkError('' + error)
+ }
+ }
+}
diff --git a/app/src/api/classroom_compliance.ts b/app/src/api/classroom_compliance.ts
index 75fd560..8e214d8 100644
--- a/app/src/api/classroom_compliance.ts
+++ b/app/src/api/classroom_compliance.ts
@@ -1,4 +1,4 @@
-import { getAuthHeaders } from '@/api/auth'
+import { APIClient, type APIResponse, type AuthStoreType } from './base'
const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
@@ -16,100 +16,120 @@ export interface Student {
removed: boolean
}
-export interface EntryResponseItemPhone {
+export interface EntryPhone {
compliant: boolean
}
-export interface EntryResponseItemBehavior {
+export interface EntryBehavior {
rating: number
comment?: string
}
-export interface EntryResponseItem {
+export interface Entry {
id: number
date: string
- createdAt: string
+ createdAt: number
absent: boolean
- phone?: EntryResponseItemPhone
- behavior?: EntryResponseItemBehavior
+ phone: EntryPhone | null
+ behavior: EntryBehavior | null
}
-export interface EntryResponseStudent {
+export function getDefaultEntry(dateStr: string): Entry {
+ return {
+ id: 0,
+ date: dateStr,
+ createdAt: Date.now(),
+ absent: false,
+ phone: { compliant: true },
+ behavior: { rating: 3 },
+ }
+}
+
+export interface EntriesResponseStudent {
id: number
name: string
deskNumber: number
removed: boolean
- entries: Record
+ entries: Record
}
export interface EntriesResponse {
- students: EntryResponseStudent[]
+ students: EntriesResponseStudent[]
dates: string[]
}
-function getClassPath(classId: number): string {
- return BASE_URL + '/classes/' + classId
+export interface StudentDataPayload {
+ name: string
+ deskNumber: number
+ removed: boolean
}
-export async function createClass(
- auth: string,
- number: number,
- schoolYear: string,
-): Promise {
- const response = await fetch(BASE_URL + '/classes', {
- method: 'POST',
- headers: getAuthHeaders(auth),
- body: JSON.stringify({ number: number, schoolYear: schoolYear }),
- })
- return (await response.json()) as Class
+export interface EntriesPayloadStudent {
+ id: number
+ entries: Record
}
-export async function getClasses(auth: string): Promise {
- const response = await fetch(BASE_URL + '/classes', {
- headers: getAuthHeaders(auth),
- })
- return (await response.json()) as Class[]
+export interface EntriesPayload {
+ students: EntriesPayloadStudent[]
}
-export async function getClass(auth: string, id: number): Promise {
- const response = await fetch(getClassPath(id), {
- headers: getAuthHeaders(auth),
- })
- return (await response.json()) as Class
-}
+export class ClassroomComplianceAPIClient extends APIClient {
+ constructor(authStore: AuthStoreType) {
+ super(BASE_URL, authStore)
+ }
-export async function deleteClass(auth: string, classId: number): Promise {
- const response = await fetch(getClassPath(classId), {
- method: 'DELETE',
- headers: getAuthHeaders(auth),
- })
- if (!response.ok) {
- throw new Error('Failed to delete class.')
+ createClass(number: number, schoolYear: string): APIResponse {
+ return super.post('/classes', { number: number, schoolYear: schoolYear })
+ }
+
+ getClasses(): APIResponse {
+ return super.get('/classes')
+ }
+
+ getClass(classId: number): APIResponse {
+ return super.get(`/classes/${classId}`)
+ }
+
+ deleteClass(classId: number): APIResponse {
+ return super.delete(`/classes/${classId}`)
+ }
+
+ getStudents(classId: number): APIResponse {
+ return super.get(`/classes/${classId}/students`)
+ }
+
+ getStudent(classId: number, studentId: number): APIResponse {
+ return super.get(`/classes/${classId}/students/${studentId}`)
+ }
+
+ createStudent(classId: number, data: StudentDataPayload): APIResponse {
+ return super.post(`/classes/${classId}/students`, data)
+ }
+
+ updateStudent(
+ classId: number,
+ studentId: number,
+ data: StudentDataPayload,
+ ): APIResponse {
+ return super.put(`/classes/${classId}/students/${studentId}`, data)
+ }
+
+ deleteStudent(classId: number, studentId: number): APIResponse {
+ return super.delete(`/classes/${classId}/students/${studentId}`)
+ }
+
+ getEntries(classId: number, fromDate?: Date, toDate?: Date): APIResponse {
+ const params = new URLSearchParams()
+ if (fromDate) {
+ params.append('from', fromDate.toISOString().substring(0, 10))
+ }
+ if (toDate) {
+ params.append('to', toDate.toISOString().substring(0, 10))
+ }
+ return super.get(`/classes/${classId}/entries?${params.toString()}`)
+ }
+
+ saveEntries(classId: number, payload: EntriesPayload): APIResponse {
+ return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload)
}
}
-
-export async function getStudents(auth: string, classId: number): Promise {
- 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 {
- 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
-}
diff --git a/app/src/apps/classroom_compliance/ClassItem.vue b/app/src/apps/classroom_compliance/ClassItem.vue
index b198f45..184accc 100644
--- a/app/src/apps/classroom_compliance/ClassItem.vue
+++ b/app/src/apps/classroom_compliance/ClassItem.vue
@@ -1,5 +1,5 @@
@@ -26,12 +38,22 @@ onMounted(async () => {
Actions:
-
-
+ Add Student
+
+
+
+
+ Are you sure you want to delete this class? All data associated with it (settings, students,
+ entries, grades, etc.) will be permanently deleted, and deleted data is not
+ recoverable.
+