diff --git a/.gitea/workflows/test-api.yaml b/.gitea/workflows/test-api.yaml new file mode 100644 index 0000000..52887e8 --- /dev/null +++ b/.gitea/workflows/test-api.yaml @@ -0,0 +1,20 @@ +name: Build and Test API +on: + push: + paths: + - 'api/**' + - '.gitea/workflows/test-api.yaml' + pull_request: + types: [opened, reopened, synchronize] +jobs: + Build-and-test-API: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup DLang + uses: dlang-community/setup-dlang@v2 + with: + compiler: ldc-latest + - name: Build + working-directory: ./api + run: dub -q build --build=release diff --git a/.gitea/workflows/test-app.yaml b/.gitea/workflows/test-app.yaml new file mode 100644 index 0000000..b6750f7 --- /dev/null +++ b/.gitea/workflows/test-app.yaml @@ -0,0 +1,31 @@ +name: Build and Test App +on: + push: + paths: + - 'app/**' + - '.gitea/workflows/test-app.yaml' + pull_request: + types: [opened, reopened, synchronize] +jobs: + Build-and-test-App: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup NodeJS + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + cache-dependency-path: app/package-lock.json + - name: Install Project + working-directory: ./app + run: npm ci + - name: Lint + working-directory: ./app + run: npm run lint + - name: Type-Check + working-directory: ./app + run: npm run type-check + - name: Build + working-directory: ./app + run: npm run build-only diff --git a/api/docker-compose.yaml b/api/docker-compose.yaml new file mode 100644 index 0000000..3d03ffd --- /dev/null +++ b/api/docker-compose.yaml @@ -0,0 +1,19 @@ +name: 'teacher-tools' +services: + postgres: + image: postgres:latest + environment: + - POSTGRES_USER=teacher-tools-dev + - POSTGRES_PASSWORD=testpass + - POSTGRES_DB=teacher-tools-dev + ports: + - "5432:5432" + restart: always + pgadmin: + image: dpage/pgadmin4:latest + environment: + - PGADMIN_DEFAULT_EMAIL=tester@example.com + - PGADMIN_DEFAULT_PASSWORD=testpass + ports: + - "5050:80" + restart: always diff --git a/api/dub.json b/api/dub.json index 17b3394..ca4ed85 100644 --- a/api/dub.json +++ b/api/dub.json @@ -5,8 +5,7 @@ "copyright": "Copyright © 2024, Andrew Lalis", "dependencies": { "asdf": "~>0.7.17", - "botan": "~>1.13.6", - "d2sqlite3": "~>1.0.0", + "ddbc": "~>0.6.2", "handy-httpd": "~>8.4.3" }, "description": "A minimal D application.", @@ -16,6 +15,6 @@ "." ], "subConfigurations": { - "d2sqlite3": "all-included" + "ddbc": "PGSQL" } } \ No newline at end of file diff --git a/api/dub.selections.json b/api/dub.selections.json index b193ec9..fd1d65f 100644 --- a/api/dub.selections.json +++ b/api/dub.selections.json @@ -2,17 +2,20 @@ "fileVersion": 1, "versions": { "asdf": "0.7.17", - "botan": "1.13.6", - "botan-math": "1.0.4", - "d2sqlite3": "1.0.0", - "handy-httpd": "8.4.3", + "d-unit": "0.10.2", + "ddbc": "0.6.2", + "derelict-pq": "2.2.0", + "derelict-util": "2.0.6", + "handy-httpd": "8.4.5", "httparsed": "1.2.1", - "memutils": "1.0.10", - "mir-algorithm": "3.22.1", + "mir-algorithm": "3.22.3", "mir-core": "1.7.1", + "mysql-native": "3.1.0", + "odbc": "1.0.0", "path-matcher": "1.2.0", "silly": "1.1.1", "slf4d": "3.0.1", - "streams": "3.5.0" + "streams": "3.5.0", + "undead": "1.1.8" } } diff --git a/api/schema/auth.sql b/api/schema/auth.sql index cdc3662..c857f10 100644 --- a/api/schema/auth.sql +++ b/api/schema/auth.sql @@ -1,8 +1,11 @@ -CREATE TABLE user ( - id INTEGER PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at INTEGER NOT NULL, - is_locked INTEGER NOT NULL, - is_admin INTEGER NOT NULL +CREATE TABLE auth_user ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE + CONSTRAINT username_check CHECK (LENGTH(username) >= 3), + password_hash VARCHAR(255) NOT NULL + CONSTRAINT password_check CHECK (LENGTH(password_hash) >= 32), + created_at BIGINT NOT NULL + DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000, + is_locked BOOLEAN NOT NULL DEFAULT FALSE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE ); diff --git a/api/schema/classroom_compliance.sql b/api/schema/classroom_compliance.sql index fbe4745..eaf109c 100644 --- a/api/schema/classroom_compliance.sql +++ b/api/schema/classroom_compliance.sql @@ -1,40 +1,44 @@ CREATE TABLE classroom_compliance_class ( - id INTEGER PRIMARY KEY, - number INTEGER NOT NULL, - school_year TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES user(id) - ON UPDATE CASCADE ON DELETE CASCADE + id BIGSERIAL PRIMARY KEY, + number INT NOT NULL + CONSTRAINT class_number_check CHECK (number > 0), + school_year VARCHAR(9) NOT NULL, + user_id BIGINT NOT NULL + REFERENCES auth_user(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT unique_class_numbers_per_school_year + UNIQUE(number, school_year, user_id) ); 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, - desk_number INTEGER NOT NULL DEFAULT 0, - removed INTEGER NOT NULL DEFAULT 0 + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + class_id BIGINT NOT NULL + REFERENCES classroom_compliance_class(id) + ON UPDATE CASCADE ON DELETE CASCADE, + desk_number INT NOT NULL DEFAULT 0, + removed BOOLEAN NOT NULL DEFAULT FALSE ); 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) - ON UPDATE CASCADE ON DELETE CASCADE, - date TEXT NOT NULL, - created_at INTEGER NOT NULL, - absent INTEGER NOT NULL DEFAULT 0, - comment TEXT NOT NULL DEFAULT '' -); - -CREATE TABLE classroom_compliance_entry_phone ( - entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id) - ON UPDATE CASCADE ON DELETE CASCADE, - compliant INTEGER NOT NULL DEFAULT 1 -); - -CREATE TABLE classroom_compliance_entry_behavior ( - entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id) - ON UPDATE CASCADE ON DELETE CASCADE, - rating INTEGER NOT NULL + id BIGSERIAL PRIMARY KEY, + class_id BIGINT NOT NULL + REFERENCES classroom_compliance_class(id) + ON UPDATE CASCADE ON DELETE CASCADE, + student_id BIGINT NOT NULL + REFERENCES classroom_compliance_student(id) + ON UPDATE CASCADE ON DELETE CASCADE, + date DATE NOT NULL, + created_at BIGINT NOT NULL + DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000, + absent BOOLEAN NOT NULL DEFAULT FALSE, + comment VARCHAR(2000) NOT NULL DEFAULT '', + phone_compliant BOOLEAN NULL DEFAULT NULL, + behavior_rating INT NULL DEFAULT NULL, + CONSTRAINT absence_nulls_check CHECK ( + (absent AND phone_compliant IS NULL AND behavior_rating IS NULL) OR + (NOT absent AND phone_compliant IS NOT NULL AND behavior_rating IS NOT NULL) + ), + CONSTRAINT unique_entry_per_date + UNIQUE(class_id, student_id, date) ); diff --git a/api/source/api_modules/auth.d b/api/source/api_modules/auth.d index aa40a59..f6b4359 100644 --- a/api/source/api_modules/auth.d +++ b/api/source/api_modules/auth.d @@ -4,10 +4,10 @@ import handy_httpd; import handy_httpd.components.optional; import handy_httpd.handlers.path_handler; import slf4d; -import d2sqlite3; import std.algorithm : map; import std.array : array; import std.json; +import ddbc; import db; import data_utils; @@ -19,6 +19,17 @@ struct User { const ulong createdAt; const bool isLocked; const bool isAdmin; + + static User parse(DataSetReader r) { + return User( + r.getUlong(1), + r.getString(2), + r.getString(3), + r.getUlong(4), + r.getBoolean(5), + r.getBoolean(6) + ); + } } private struct UserResponse { @@ -42,7 +53,7 @@ private string encodePassword(string password) { return toHexString(sha256Of(password)).idup; } -private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, ref Database db) { +private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connection conn) { import std.base64; import std.string : startsWith; import std.digest.sha; @@ -57,7 +68,12 @@ private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, ref Datab size_t idx = countUntil(decoded, ':'); string username = decoded[0..idx]; auto passwordHash = encodePassword(decoded[idx+1 .. $]); - Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username); + Optional!User optUser = findOne( + conn, + "SELECT * FROM auth_user WHERE username = ?", + &User.parse, + username + ); if ( // Reject the user's authentication, even if they exist, if: !optUser.isNull && ( @@ -75,11 +91,11 @@ private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, ref Datab * authentication header. * Params: * ctx = The request context. - * db = The database to query. + * conn = The database connection. * Returns: The user that made the request. Otherwise, a 401 is thrown. */ -User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) { - Optional!User optUser = getUserFromBasicAuth(ctx, db); +User getUserOrThrow(ref HttpRequestContext ctx, Connection conn) { + Optional!User optUser = getUserFromBasicAuth(ctx, conn); if (optUser.isNull) { throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); } @@ -90,11 +106,11 @@ User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) { * Similar to `getUserOrThrow`, but throws a 403 if the user isn't an admin. * Params: * ctx = The request context. - * db = The database to query. + * conn = The database connection. * Returns: The user that made the request. */ -User getAdminUserOrThrow(ref HttpRequestContext ctx, ref Database db) { - User user = getUserOrThrow(ctx, db); +User getAdminUserOrThrow(ref HttpRequestContext ctx, Connection conn) { + User user = getUserOrThrow(ctx, conn); if (!user.isAdmin) { throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource."); } @@ -102,8 +118,9 @@ User getAdminUserOrThrow(ref HttpRequestContext ctx, ref Database db) { } private void loginEndpoint(ref HttpRequestContext ctx) { - Database db = getDb(); - Optional!User optUser = getUserFromBasicAuth(ctx, db); + Connection conn = getDb(); + scope(exit) conn.close(); + Optional!User optUser = getUserFromBasicAuth(ctx, conn); if (optUser.isNull) { ctx.response.status = HttpStatus.UNAUTHORIZED; ctx.response.writeBodyString("Invalid credentials."); @@ -120,8 +137,9 @@ private void loginEndpoint(ref HttpRequestContext ctx) { } private void usersAdminEndpoint(ref HttpRequestContext ctx) { - Database db = getDb(); - User user = getAdminUserOrThrow(ctx, db); + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getAdminUserOrThrow(ctx, conn); uint page = ctx.request.getParamAs!uint("page", 0); uint pageSize = ctx.request.getParamAs!uint("size", 30); if (page < 0) page = 0; @@ -129,16 +147,22 @@ private void usersAdminEndpoint(ref HttpRequestContext ctx) { if (pageSize > 100) pageSize = 100; uint offset = page * pageSize; - const query = "SELECT * FROM user ORDER BY created_at DESC LIMIT ? OFFSET ?"; - UserResponse[] users = findAll!(User)(db, query, pageSize, offset) + const query = "SELECT * FROM auth_user ORDER BY created_at DESC LIMIT ? OFFSET ?"; + UserResponse[] users = findAll( + conn, + query, + &User.parse, + pageSize, offset + ) .map!(u => UserResponse(u.id, u.username, u.createdAt, u.isLocked, u.isAdmin)) .array; writeJsonBody(ctx, users); } private void createUserAdminEndpoint(ref HttpRequestContext ctx) { - Database db = getDb(); - User user = getAdminUserOrThrow(ctx, db); + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getAdminUserOrThrow(ctx, conn); struct Payload { string username; string password; @@ -147,17 +171,20 @@ private void createUserAdminEndpoint(ref HttpRequestContext ctx) { // TODO: Validate data string passwordHash = encodePassword(payload.password); const query = " - INSERT INTO user ( - username, - password_hash, - created_at, - is_locked, - is_admin - ) VALUES (?, ?, ?, ?, ?) + INSERT INTO auth_user ( + username, + password_hash, + is_locked, + is_admin + ) VALUES (?, ?, ?, ?) + RETURNING id "; - db.execute(query, payload.username, passwordHash, getUnixTimestampMillis(), false, false); - ulong newUserId = db.lastInsertRowid(); - User newUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", newUserId).orElseThrow(); + ulong newUserId = insertOne( + conn, + query, + payload.username, passwordHash, false, false + ); + User newUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, newUserId).orElseThrow(); writeJsonBody(ctx, UserResponse( newUser.id, newUser.username, @@ -168,20 +195,28 @@ private void createUserAdminEndpoint(ref HttpRequestContext ctx) { } private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) { - Database db = getDb(); - User user = getAdminUserOrThrow(ctx, db); + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getAdminUserOrThrow(ctx, conn); ulong targetUserId = ctx.request.getPathParamAs!ulong("userId"); - Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId); + Optional!User targetUser = findOne( + conn, + "SELECT * FROM auth_user WHERE id = ?", + &User.parse, + targetUserId + ); if (!targetUser.isNull) { - db.execute("DELETE FROM user WHERE id = ?", targetUserId); + update(conn, "DELETE FROM auth_user WHERE id = ?", targetUserId); } } private void updateUserAdminEndpoint(ref HttpRequestContext ctx) { - Database db = getDb(); - User user = getAdminUserOrThrow(ctx, db); + Connection conn = getDb(); + scope(exit) conn.close(); + conn.setAutoCommit(false); + User user = getAdminUserOrThrow(ctx, conn); ulong targetUserId = ctx.request.getPathParamAs!ulong("userId"); - Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId); + Optional!User targetUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId); if (targetUser.isNull) { ctx.response.status = HttpStatus.NOT_FOUND; ctx.response.writeBodyString("User not found."); @@ -193,7 +228,6 @@ private void updateUserAdminEndpoint(ref HttpRequestContext ctx) { ctx.response.writeBodyString("Expected JSON object with user properties."); return; } - db.begin(); try { if ("username" in payload.object) { string newUsername = payload.object["username"].str; @@ -201,33 +235,34 @@ private void updateUserAdminEndpoint(ref HttpRequestContext ctx) { if (newUsername.length < 3 || newUsername.length > 32) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Invalid username."); - db.rollback(); + conn.rollback(); return; } - if (canFind(db, "SELECT id FROM user WHERE username = ?", newUsername)) { + if (recordExists(conn, "SELECT id FROM auth_user WHERE username = ?", newUsername)) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Username already taken."); - db.rollback(); + conn.rollback(); return; } - db.execute("UPDATE user SET username = ? WHERE id = ?", newUsername, targetUserId); + update(conn, "UPDATE auth_user SET username = ? WHERE id = ?", newUsername, targetUserId); } } if ("password" in payload.object) { string rawPassword = payload.object["password"].str; string newPasswordHash = encodePassword(rawPassword); if (newPasswordHash != targetUser.value.passwordHash) { - db.execute("UPDATE user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId); + update(conn, "UPDATE auth_user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId); } } if ("isLocked" in payload.object) { bool newIsLocked = payload.object["isLocked"].boolean; if (newIsLocked != targetUser.value.isLocked) { - db.execute("UPDATE user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId); + update(conn, "UPDATE auth_user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId); } } - db.commit(); - User updatedUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId).orElseThrow(); + conn.commit(); + User updatedUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId) + .orElseThrow(); writeJsonBody(ctx, UserResponse( updatedUser.id, updatedUser.username, @@ -236,7 +271,7 @@ private void updateUserAdminEndpoint(ref HttpRequestContext ctx) { updatedUser.isAdmin )); } catch (Exception e) { - db.rollback(); + conn.rollback(); ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR; ctx.response.writeBodyString("Something went wrong: " ~ e.msg); } diff --git a/api/source/api_modules/classroom_compliance.d b/api/source/api_modules/classroom_compliance.d deleted file mode 100644 index 38f0184..0000000 --- a/api/source/api_modules/classroom_compliance.d +++ /dev/null @@ -1,887 +0,0 @@ -module api_modules.classroom_compliance; - -import handy_httpd; -import handy_httpd.handlers.path_handler; -import d2sqlite3; -import slf4d; -import std.typecons : Nullable; -import std.datetime; -import std.json; -import std.algorithm; -import std.array; - -import db; -import data_utils; -import api_modules.auth : User, getUserOrThrow; - -struct ClassroomComplianceClass { - const ulong id; - const ushort number; - const string schoolYear; - const ulong userId; -} - -struct ClassroomComplianceStudent { - const ulong id; - const string name; - const ulong classId; - const ushort deskNumber; - const bool removed; -} - -struct ClassroomComplianceEntry { - const ulong id; - const ulong classId; - const ulong studentId; - const string date; - const ulong createdAt; - const bool absent; - const string comment; -} - -struct ClassroomComplianceEntryPhone { - const ulong entryId; - const bool compliant; -} - -struct ClassroomComplianceEntryBehavior { - const ulong entryId; - const ubyte rating; -} - -void registerApiEndpoints(PathHandler handler) { - const ROOT_PATH = "/api/classroom-compliance"; - - handler.addMapping(Method.POST, ROOT_PATH ~ "/classes", &createClass); - handler.addMapping(Method.GET, ROOT_PATH ~ "/classes", &getClasses); - const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong"; - handler.addMapping(Method.GET, CLASS_PATH, &getClass); - handler.addMapping(Method.DELETE, CLASS_PATH, &deleteClass); - - handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent); - handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents); - const STUDENT_PATH = CLASS_PATH ~ "/students/:studentId:ulong"; - handler.addMapping(Method.GET, STUDENT_PATH, &getStudent); - handler.addMapping(Method.PUT, STUDENT_PATH, &updateStudent); - handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent); - handler.addMapping(Method.PUT, STUDENT_PATH ~ "/class", &moveStudentToOtherClass); - handler.addMapping(Method.GET, STUDENT_PATH ~ "/entries", &getStudentEntries); - handler.addMapping(Method.GET, STUDENT_PATH ~ "/overview", &getStudentOverview); - - handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries); - handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries); -} - -void createClass(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - struct ClassPayload { - ushort number; - string schoolYear; - } - auto payload = readJsonPayload!(ClassPayload)(ctx); - const bool classNumberExists = canFind( - db, - "SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?", - payload.number, - payload.schoolYear, - user.id - ); - if (classNumberExists) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("There is already a class with this number, for the same school year."); - return; - } - auto stmt = db.prepare("INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?)"); - stmt.bindAll(payload.number, payload.schoolYear, user.id); - stmt.execute(); - ulong classId = db.lastInsertRowid(); - auto newClass = findOne!(ClassroomComplianceClass)( - db, - "SELECT * FROM classroom_compliance_class WHERE id = ? AND user_id = ?", - classId, - user.id - ).orElseThrow(); - writeJsonBody(ctx, newClass); -} - -void getClasses(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto classes = findAll!(ClassroomComplianceClass)( - db, - "SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC", - user.id - ); - writeJsonBody(ctx, classes); -} - -void getClass(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto cls = getClassOrThrow(ctx, db, user); - writeJsonBody(ctx, cls); -} - -void deleteClass(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto cls = getClassOrThrow(ctx, db, user); - db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id); -} - -ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) { - ulong classId = ctx.request.getPathParamAs!ulong("classId"); - return findOne!(ClassroomComplianceClass)( - db, - "SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?", - user.id, - classId - ).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); -} - -void createStudent(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto cls = getClassOrThrow(ctx, db, user); - struct StudentPayload { - string name; - ushort deskNumber; - bool removed; - } - auto payload = readJsonPayload!(StudentPayload)(ctx); - bool studentExists = canFind( - db, - "SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", - payload.name, - cls.id - ); - if (studentExists) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("A student with that name already exists in this class."); - return; - } - bool deskAlreadyOccupied = payload.deskNumber != 0 && canFind( - db, - "SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?", - cls.id, - payload.deskNumber - ); - if (deskAlreadyOccupied) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("There is already a student assigned to that desk number."); - } - db.execute( - "INSERT INTO classroom_compliance_student (name, class_id, desk_number, removed) VALUES (?, ?, ?, ?)", - payload.name, cls.id, payload.deskNumber, payload.removed - ); - ulong studentId = db.lastInsertRowid(); - auto student = findOne!(ClassroomComplianceStudent)( - db, - "SELECT * FROM classroom_compliance_student WHERE id = ?", - studentId - ).orElseThrow(); - writeJsonBody(ctx, student); -} - -void getStudents(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto cls = getClassOrThrow(ctx, db, user); - auto students = findAll!(ClassroomComplianceStudent)( - db, - "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", - cls.id - ); - writeJsonBody(ctx, students); -} - -void getStudent(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto student = getStudentOrThrow(ctx, db, user); - writeJsonBody(ctx, student); -} - -void updateStudent(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto student = getStudentOrThrow(ctx, db, user); - struct StudentUpdatePayload { - string name; - ushort deskNumber; - bool removed; - } - auto payload = readJsonPayload!(StudentUpdatePayload)(ctx); - // If there is nothing to update, quit. - if ( - payload.name == student.name - && payload.deskNumber == student.deskNumber - && payload.removed == student.removed - ) return; - // Check that the new name doesn't already exist. - bool newNameExists = payload.name != student.name && canFind( - db, - "SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", - payload.name, - student.classId - ); - if (newNameExists) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("A student with that name already exists in this class."); - return; - } - // Check that if a new desk number is assigned, that it's not already assigned to anyone else. - bool newDeskOccupied = payload.deskNumber != 0 && payload.deskNumber != student.deskNumber && canFind( - db, - "SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?", - student.classId, - payload.deskNumber - ); - if (newDeskOccupied) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("That desk is already assigned to another student."); - return; - } - db.execute( - "UPDATE classroom_compliance_student SET name = ?, desk_number = ?, removed = ? WHERE id = ?", - payload.name, - payload.deskNumber, - payload.removed, - student.id - ); - auto updatedStudent = findOne!(ClassroomComplianceStudent)( - db, - "SELECT * FROM classroom_compliance_student WHERE id = ?", - student.id - ).orElseThrow(); - writeJsonBody(ctx, updatedStudent); -} - -void deleteStudent(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto student = getStudentOrThrow(ctx, db, user); - db.execute( - "DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?", - student.id, - student.classId - ); -} - -ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) { - ulong classId = ctx.request.getPathParamAs!ulong("classId"); - ulong studentId = ctx.request.getPathParamAs!ulong("studentId"); - string query = " - SELECT s.* - FROM classroom_compliance_student s - LEFT JOIN classroom_compliance_class c ON s.class_id = c.id - WHERE s.id = ? AND s.class_id = ? AND c.user_id = ? - "; - return findOne!(ClassroomComplianceStudent)( - db, - query, - studentId, - classId, - user.id - ).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); -} - -void getEntries(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto cls = getClassOrThrow(ctx, db, user); - // Default to getting entries from the last 5 days. - SysTime now = Clock.currTime(); - Date toDate = Date(now.year, now.month, now.day); - Date fromDate = toDate - days(4); - if (ctx.request.queryParams.contains("to")) { - try { - toDate = Date.fromISOExtString(ctx.request.queryParams.getFirst("to").orElse("")); - } catch (DateTimeException e) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid \"to\" date."); - return; - } - } - if (ctx.request.queryParams.contains("from")) { - try { - fromDate = Date.fromISOExtString(ctx.request.queryParams.getFirst("from").orElse("")); - } catch (DateTimeException e) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid \"from\" date."); - return; - } - } - if (fromDate > toDate) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid date range. From-date must be less than or equal to the to-date."); - return; - } - if (toDate - fromDate > days(10)) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Date range is too big. Only ranges of 10 days or less are allowed."); - return; - } - infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString()); - - // First prepare a list of all students, including ones which don't have any entries. - ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)( - db, - "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", - cls.id - ); - JSONValue[] studentObjects = students.map!((s) { - JSONValue obj = JSONValue.emptyObject; - obj.object["id"] = JSONValue(s.id); - obj.object["deskNumber"] = JSONValue(s.deskNumber); - obj.object["name"] = JSONValue(s.name); - obj.object["removed"] = JSONValue(s.removed); - obj.object["entries"] = JSONValue.emptyObject; - obj.object["score"] = JSONValue(null); - return obj; - }).array; - - const entriesQuery = " - SELECT - entry.id, - entry.date, - entry.created_at, - entry.absent, - entry.comment, - student.id, - student.name, - student.desk_number, - student.removed, - phone.compliant, - behavior.rating - FROM classroom_compliance_entry entry - LEFT JOIN classroom_compliance_entry_phone phone - ON phone.entry_id = entry.id - LEFT JOIN classroom_compliance_entry_behavior behavior - ON behavior.entry_id = entry.id - LEFT JOIN classroom_compliance_student student - ON student.id = entry.student_id - WHERE - entry.class_id = ? - AND entry.date >= ? - AND entry.date <= ? - ORDER BY - student.id ASC, - entry.date ASC - "; - ResultRange entriesResult = db.execute(entriesQuery, cls.id, fromDate.toISOExtString(), toDate.toISOExtString()); - // Serialize the results into a custom-formatted response object. - foreach (row; entriesResult) { - JSONValue entry = JSONValue.emptyObject; - entry.object["id"] = JSONValue(row.peek!ulong(0)); - entry.object["date"] = JSONValue(row.peek!string(1)); - entry.object["createdAt"] = JSONValue(row.peek!ulong(2)); - entry.object["absent"] = JSONValue(row.peek!bool(3)); - entry.object["comment"] = JSONValue(row.peek!string(4)); - - JSONValue phone = JSONValue(null); - JSONValue behavior = JSONValue(null); - if (!entry.object["absent"].boolean()) { - phone = JSONValue.emptyObject; - phone.object["compliant"] = JSONValue(row.peek!bool(9)); - behavior = JSONValue.emptyObject; - behavior.object["rating"] = JSONValue(row.peek!ubyte(10)); - } - entry.object["phone"] = phone; - entry.object["behavior"] = behavior; - string dateStr = entry.object["date"].str(); - - // Find the student object this entry belongs to, then add it to their list. - ulong studentId = row.peek!ulong(5); - bool studentFound = false; - foreach (idx, studentObj; studentObjects) { - if (studentObj.object["id"].uinteger == studentId) { - studentObj.object["entries"].object[dateStr] = entry; - studentFound = true; - break; - } - } - if (!studentFound) { - // The student isn't in our list of original students from the class, so it's a student who's moved to another. - JSONValue obj = JSONValue.emptyObject; - obj.object["id"] = JSONValue(studentId); - obj.object["deskNumber"] = JSONValue(row.peek!ushort(7)); - obj.object["name"] = JSONValue(row.peek!string(6)); - obj.object["removed"] = JSONValue(row.peek!bool(8)); - obj.object["entries"] = JSONValue.emptyObject; - obj.object["entries"].object[dateStr] = entry; - obj.object["score"] = JSONValue(null); - studentObjects ~= obj; - } - } - - // Find scores for each student for this timeframe. - Optional!double[ulong] scores = getScores(db, cls.id, fromDate, toDate); - foreach (studentId, score; scores) { - JSONValue scoreValue = score.isNull ? JSONValue(null) : JSONValue(score.value); - bool studentFound = false; - foreach (studentObj; studentObjects) { - if (studentObj.object["id"].uinteger == studentId) { - studentObj.object["score"] = scoreValue; - studentFound = true; - break; - } - } - if (!studentFound) { - throw new Exception("Failed to find student."); - } - } - - JSONValue response = JSONValue.emptyObject; - // Provide the list of dates that we're providing data for, to make it easier for the frontend. - response.object["dates"] = JSONValue.emptyArray; - - // Also fill in "null" for any students that don't have an entry on one of these days. - Date d = fromDate; - while (d <= toDate) { - string dateStr = d.toISOExtString(); - response.object["dates"].array ~= JSONValue(dateStr); - foreach (studentObj; studentObjects) { - if (dateStr !in studentObj.object["entries"].object) { - studentObj.object["entries"].object[dateStr] = JSONValue(null); - } - } - d += days(1); - } - response.object["students"] = JSONValue(studentObjects); - - string jsonStr = response.toJSON(); - ctx.response.writeBodyString(jsonStr, "application/json"); -} - -void saveEntries(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto cls = getClassOrThrow(ctx, db, user); - JSONValue bodyContent = ctx.request.readBodyAsJson(); - db.begin(); - try { - foreach (JSONValue studentObj; bodyContent.object["students"].array) { - ulong studentId = studentObj.object["id"].integer(); - JSONValue entries = studentObj.object["entries"]; - foreach (string dateStr, JSONValue entry; entries.object) { - if (entry.isNull) { - deleteEntry(db, cls.id, studentId, dateStr); - continue; - } - - Optional!ClassroomComplianceEntry existingEntry = findOne!(ClassroomComplianceEntry)( - db, - "SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", - cls.id, studentId, dateStr - ); - - ulong entryId = entry.object["id"].integer(); - bool creatingNewEntry = entryId == 0; - - if (creatingNewEntry) { - if (!existingEntry.isNull) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Cannot create a new entry when one already exists."); - return; - } - - insertNewEntry(db, cls.id, studentId, dateStr, entry); - } else { - if (existingEntry.isNull) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Cannot update an entry which doesn't exist."); - return; - } - updateEntry(db, cls.id, studentId, dateStr, entryId, entry); - } - } - } - db.commit(); - } catch (HttpStatusException e) { - db.rollback(); - ctx.response.status = e.status; - ctx.response.writeBodyString(e.message); - } catch (JSONException e) { - db.rollback(); - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid JSON payload."); - warn(e); - } catch (Exception e) { - db.rollback(); - ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR; - ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg); - error(e); - } -} - -private void deleteEntry( - ref Database db, - ulong classId, - ulong studentId, - string dateStr -) { - db.execute( - "DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", - classId, - studentId, - dateStr - ); - infoF!"Deleted entry for student %s on %s"(studentId, dateStr); -} - -private void insertNewEntry( - ref Database db, - ulong classId, - ulong studentId, - string dateStr, - JSONValue payload -) { - ulong createdAt = getUnixTimestampMillis(); - bool absent = payload.object["absent"].boolean; - string comment = payload.object["comment"].str; - if (comment is null) comment = ""; - db.execute( - "INSERT INTO classroom_compliance_entry - (class_id, student_id, date, created_at, absent, comment) - VALUES (?, ?, ?, ?, ?, ?)", - classId, studentId, dateStr, createdAt, absent, comment - ); - if (!absent) { - ulong entryId = db.lastInsertRowid(); - if ("phone" !in payload.object || payload.object["phone"].type != JSONType.object) { - throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing phone data."); - } - if ("behavior" !in payload.object || payload.object["behavior"].type != JSONType.object) { - throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing behavior data."); - } - bool phoneCompliance = payload.object["phone"].object["compliant"].boolean; - db.execute( - "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) - VALUES (?, ?)", - entryId, phoneCompliance - ); - ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer; - db.execute( - "INSERT INTO classroom_compliance_entry_behavior (entry_id, rating) - VALUES (?, ?)", - entryId, behaviorRating - ); - } - infoF!"Created new entry for student %d: %s"(studentId, payload); -} - -private void updateEntry( - ref Database db, - ulong classId, - ulong studentId, - string dateStr, - ulong entryId, - JSONValue obj -) { - bool absent = obj.object["absent"].boolean; - string comment = obj.object["comment"].str; - if (comment is null) comment = ""; - db.execute( - "UPDATE classroom_compliance_entry - SET absent = ?, comment = ? - WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?", - absent, comment, - classId, studentId, dateStr, entryId - ); - if (absent) { - db.execute( - "DELETE FROM classroom_compliance_entry_phone WHERE entry_id = ?", - entryId - ); - db.execute( - "DELETE FROM classroom_compliance_entry_behavior WHERE entry_id = ?", - entryId - ); - } else { - bool phoneCompliant = obj.object["phone"].object["compliant"].boolean; - bool phoneDataExists = canFind( - db, - "SELECT * FROM classroom_compliance_entry_phone WHERE entry_id = ?", - entryId - ); - - if (phoneDataExists) { - db.execute( - "UPDATE classroom_compliance_entry_phone - SET compliant = ? - WHERE entry_id = ?", - phoneCompliant, - entryId - ); - } else { - db.execute( - "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) - VALUES (?, ?)", - entryId, phoneCompliant - ); - } - - ubyte behaviorRating = cast(ubyte) obj.object["behavior"].object["rating"].integer; - bool behaviorDataExists = canFind( - db, - "SELECT * FROM classroom_compliance_entry_behavior WHERE entry_id = ?", - entryId - ); - if (behaviorDataExists) { - db.execute( - "UPDATE classroom_compliance_entry_behavior - SET rating = ? - WHERE entry_id = ?", - behaviorRating, - entryId - ); - } else { - db.execute( - "INSERT INTO classroom_compliance_entry_behavior (entry_id, rating) - VALUES (?, ?)", - entryId, behaviorRating - ); - } - } - infoF!"Updated entry %d"(entryId); -} - -Optional!double[ulong] getScores( - ref Database db, - ulong classId, - Date fromDate, - Date toDate -) { - infoF!"Getting scores from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString()); - - // First populate all students with an initial "null" score. - Optional!double[ulong] scores; - ResultRange studentsResult = db.execute( - "SELECT id FROM classroom_compliance_student WHERE class_id = ?", - classId - ); - foreach (row; studentsResult) { - scores[row.peek!ulong(0)] = Optional!double.empty; - } - - const query = " - SELECT - e.student_id, - COUNT(e.id) AS entry_count, - SUM(e.absent) AS absence_count, - SUM(NOT p.compliant) AS phone_noncompliance_count, - SUM(b.rating = 3) AS behavior_good, - SUM(b.rating = 2) AS behavior_mediocre, - SUM(b.rating = 1) AS behavior_poor - FROM classroom_compliance_entry e - LEFT JOIN classroom_compliance_entry_phone p - ON p.entry_id = e.id - LEFT JOIN classroom_compliance_entry_behavior b - ON b.entry_id = e.id - WHERE - e.date >= ? - AND e.date <= ? - AND e.class_id = ? - GROUP BY e.student_id - "; - ResultRange result = db.execute( - query, - fromDate.toISOExtString(), - toDate.toISOExtString(), - classId - ); - foreach (row; result) { - ulong studentId = row.peek!ulong(0); - uint entryCount = row.peek!uint(1); - uint absenceCount = row.peek!uint(2); - uint phoneNonComplianceCount = row.peek!uint(3); - uint behaviorGoodCount = row.peek!uint(4); - uint behaviorMediocreCount = row.peek!uint(5); - uint behaviorPoorCount = row.peek!uint(6); - scores[studentId] = calculateScore( - entryCount, - absenceCount, - phoneNonComplianceCount, - behaviorGoodCount, - behaviorMediocreCount, - behaviorPoorCount - ); - } - return scores; -} - -private Optional!double calculateScore( - uint entryCount, - uint absenceCount, - uint phoneNonComplianceCount, - uint behaviorGoodCount, - uint behaviorMediocreCount, - uint behaviorPoorCount -) { - if ( - entryCount == 0 - || entryCount <= absenceCount - ) return Optional!double.empty; - - const uint presentCount = entryCount - absenceCount; - - // Phone subscore: - uint phoneCompliantCount; - if (presentCount < phoneNonComplianceCount) { - phoneCompliantCount = 0; - } else { - phoneCompliantCount = presentCount - phoneNonComplianceCount; - } - double phoneScore = phoneCompliantCount / cast(double) presentCount; - - // Behavior subscore: - double behaviorScore = ( - behaviorGoodCount * 1.0 - + behaviorMediocreCount * 0.5 - + behaviorPoorCount * 0 - ) / cast(double) presentCount; - - double score = 0.3 * phoneScore + 0.7 * behaviorScore; - return Optional!double.of(score); -} - -void moveStudentToOtherClass(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto student = getStudentOrThrow(ctx, db, user); - struct Payload { - ulong classId; - } - Payload payload = readJsonPayload!(Payload)(ctx); - if (payload.classId == student.classId) { - return; // Quit if the student is already in the desired class. - } - // Check that the desired class exists, and belongs to the user. - bool newClassIdValid = canFind( - db, - "SELECT id FROM classroom_compliance_class WHERE user_id = ? and id = ?", - user.id, payload.classId - ); - if (!newClassIdValid) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid class was selected."); - return; - } - // All good, so update the student's class to the desired one, and reset their desk. - db.execute( - "UPDATE classroom_compliance_student SET class_id = ?, desk_number = 0 WHERE id = ?", - payload.classId, - student.id - ); - // We just return 200 OK, no response body. -} - -void getStudentEntries(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto student = getStudentOrThrow(ctx, db, user); - - const query = " - SELECT - e.id, - e.date, - e.created_at, - e.absent, - e.comment, - p.compliant, - b.rating - FROM classroom_compliance_entry e - LEFT JOIN classroom_compliance_entry_phone p - ON p.entry_id = e.id - LEFT JOIN classroom_compliance_entry_behavior b - ON b.entry_id = e.id - WHERE - e.student_id = ? - ORDER BY e.date DESC - "; - JSONValue response = JSONValue.emptyArray; - foreach (row; db.execute(query, student.id)) { - JSONValue e = JSONValue.emptyObject; - bool absent = row.peek!bool(3); - e.object["id"] = JSONValue(row.peek!ulong(0)); - e.object["date"] = JSONValue(row.peek!string(1)); - e.object["createdAt"] = JSONValue(row.peek!ulong(2)); - e.object["absent"] = JSONValue(absent); - e.object["comment"] = JSONValue(row.peek!string(4)); - if (absent) { - e.object["phone"] = JSONValue(null); - e.object["behavior"] = JSONValue(null); - } else { - JSONValue phone = JSONValue.emptyObject; - phone.object["compliant"] = JSONValue(row.peek!bool(5)); - e.object["phone"] = phone; - JSONValue behavior = JSONValue.emptyObject; - behavior.object["rating"] = JSONValue(row.peek!ubyte(6)); - e.object["behavior"] = behavior; - } - response.array ~= e; - } - ctx.response.writeBodyString(response.toJSON(), "application/json"); -} - -void getStudentOverview(ref HttpRequestContext ctx) { - auto db = getDb(); - User user = getUserOrThrow(ctx, db); - auto student = getStudentOrThrow(ctx, db, user); - - const ulong entryCount = findOne!ulong( - db, - "SELECT COUNT(*) FROM classroom_compliance_entry WHERE student_id = ?", - student.id - ).orElse(0); - if (entryCount == 0) { - ctx.response.status = HttpStatus.NOT_FOUND; - ctx.response.writeBodyString("No entries for this student."); - return; - } - const ulong absenceCount = findOne!ulong( - db, - "SELECT COUNT(*) FROM classroom_compliance_entry WHERE student_id = ? AND absent = true", - student.id - ).orElse(0); - const ulong phoneNoncomplianceCount = findOne!ulong( - db, - " - SELECT COUNT(*) - FROM classroom_compliance_entry_phone p - LEFT JOIN classroom_compliance_entry e - ON e.id = p.entry_id - WHERE p.compliant = false AND e.student_id = ? - ", - student.id - ).orElse(0); - const behaviorCountQuery = " - SELECT COUNT(*) - FROM classroom_compliance_entry_behavior b - LEFT JOIN classroom_compliance_entry e - ON e.id = b.entry_id - WHERE e.student_id = ? AND b.rating = ? - "; - - const ulong behaviorGoodCount = findOne!ulong(db, behaviorCountQuery, student.id, 3).orElse(0); - const ulong behaviorMediocreCount = findOne!ulong(db, behaviorCountQuery, student.id, 2).orElse(0); - const ulong behaviorPoorCount = findOne!ulong(db, behaviorCountQuery, student.id, 1).orElse(0); - - // Calculate derived statistics. - const ulong attendanceCount = entryCount - absenceCount; - double attendanceRate = attendanceCount / cast(double) entryCount; - double phoneComplianceRate = (attendanceCount - phoneNoncomplianceCount) / cast(double) attendanceCount; - double behaviorScore = ( - behaviorGoodCount * 1.0 + - behaviorMediocreCount * 0.5 - ) / attendanceCount; - - JSONValue response = JSONValue.emptyObject; - response.object["attendanceRate"] = JSONValue(attendanceRate); - response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate); - response.object["behaviorScore"] = JSONValue(behaviorScore); - response.object["entryCount"] = JSONValue(entryCount); - ctx.response.writeBodyString(response.toJSON(), "application/json"); -} diff --git a/api/source/api_modules/classroom_compliance/api.d b/api/source/api_modules/classroom_compliance/api.d new file mode 100644 index 0000000..e78e9bd --- /dev/null +++ b/api/source/api_modules/classroom_compliance/api.d @@ -0,0 +1,31 @@ +module api_modules.classroom_compliance.api; + +import handy_httpd.handlers.path_handler : PathHandler; +import handy_httpd.components.request : Method; + +import api_modules.classroom_compliance.api_class; +import api_modules.classroom_compliance.api_student; +import api_modules.classroom_compliance.api_entry; + +void registerApiEndpoints(PathHandler handler) { + const ROOT_PATH = "/api/classroom-compliance"; + + handler.addMapping(Method.POST, ROOT_PATH ~ "/classes", &createClass); + handler.addMapping(Method.GET, ROOT_PATH ~ "/classes", &getClasses); + const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong"; + handler.addMapping(Method.GET, CLASS_PATH, &getClass); + handler.addMapping(Method.DELETE, CLASS_PATH, &deleteClass); + + handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent); + handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents); + const STUDENT_PATH = CLASS_PATH ~ "/students/:studentId:ulong"; + handler.addMapping(Method.GET, STUDENT_PATH, &getStudent); + handler.addMapping(Method.PUT, STUDENT_PATH, &updateStudent); + handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent); + handler.addMapping(Method.PUT, STUDENT_PATH ~ "/class", &moveStudentToOtherClass); + handler.addMapping(Method.GET, STUDENT_PATH ~ "/entries", &getStudentEntries); + handler.addMapping(Method.GET, STUDENT_PATH ~ "/overview", &getStudentOverview); + + handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries); + handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries); +} \ No newline at end of file diff --git a/api/source/api_modules/classroom_compliance/api_class.d b/api/source/api_modules/classroom_compliance/api_class.d new file mode 100644 index 0000000..4f2eee0 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/api_class.d @@ -0,0 +1,77 @@ +module api_modules.classroom_compliance.api_class; + +import handy_httpd; +import ddbc; + +import api_modules.classroom_compliance.model; +import api_modules.classroom_compliance.util; +import api_modules.auth : User, getUserOrThrow; +import db; +import data_utils; + +void createClass(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + struct ClassPayload { + ushort number; + string schoolYear; + } + auto payload = readJsonPayload!(ClassPayload)(ctx); + const bool classNumberExists = recordExists( + conn, + "SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?", + payload.number, payload.schoolYear, user.id + ); + if (classNumberExists) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("There is already a class with this number, for the same school year."); + return; + } + ulong classId = insertOne( + conn, + "INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?) RETURNING id", + payload.number, payload.schoolYear, user.id + ); + auto newClass = findOne( + conn, + "SELECT * FROM classroom_compliance_class WHERE id = ? AND user_id = ?", + &ClassroomComplianceClass.parse, + classId, user.id + ).orElseThrow(); + writeJsonBody(ctx, newClass); +} + +void getClasses(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto classes = findAll( + conn, + "SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC", + &ClassroomComplianceClass.parse, + user.id + ); + writeJsonBody(ctx, classes); +} + +void getClass(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + writeJsonBody(ctx, cls); +} + +void deleteClass(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + const query = "DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?"; + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + ps.setUlong(1, cls.id); + ps.setUlong(2, user.id); + ps.executeUpdate(); +} diff --git a/api/source/api_modules/classroom_compliance/api_entry.d b/api/source/api_modules/classroom_compliance/api_entry.d new file mode 100644 index 0000000..18c8860 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/api_entry.d @@ -0,0 +1,508 @@ +module api_modules.classroom_compliance.api_entry; + +import handy_httpd; +import handy_httpd.components.optional; +import ddbc; +import std.datetime; +import std.json; +import std.algorithm : map; +import std.array; +import slf4d; + +import api_modules.auth; +import api_modules.classroom_compliance.model; +import api_modules.classroom_compliance.util; +import db; +import data_utils; + +struct EntriesTableEntry { + ulong id; + Date date; + ulong createdAt; + bool absent; + string comment; + Optional!bool phoneCompliant; + Optional!ubyte behaviorRating; + + JSONValue toJsonObj() const { + JSONValue obj = JSONValue.emptyObject; + obj.object["id"] = JSONValue(id); + obj.object["date"] = JSONValue(date.toISOExtString()); + obj.object["createdAt"] = JSONValue(createdAt); + obj.object["absent"] = JSONValue(absent); + obj.object["comment"] = JSONValue(comment); + if (absent) { + obj.object["phoneCompliant"] = JSONValue(null); + obj.object["behaviorRating"] = JSONValue(null); + } else { + obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value); + obj.object["behaviorRating"] = JSONValue(behaviorRating.value); + } + return obj; + } +} + +struct EntriesTableStudentResponse { + ulong id; + string name; + ushort deskNumber; + bool removed; + EntriesTableEntry[string] entries; + Optional!double score; + + JSONValue toJsonObj() const { + JSONValue obj = JSONValue.emptyObject; + obj.object["id"] = JSONValue(id); + obj.object["name"] = JSONValue(name); + obj.object["deskNumber"] = JSONValue(deskNumber); + obj.object["removed"] = JSONValue(removed); + JSONValue entriesSet = JSONValue.emptyObject; + foreach (dateStr, entry; entries) { + entriesSet.object[dateStr] = entry.toJsonObj(); + } + obj.object["entries"] = entriesSet; + if (score.isNull) { + obj.object["score"] = JSONValue(null); + } else { + obj.object["score"] = JSONValue(score.value); + } + return obj; + } +} + +struct EntriesTableResponse { + EntriesTableStudentResponse[] students; + string[] dates; + + JSONValue toJsonObj() const { + JSONValue obj = JSONValue.emptyObject; + obj.object["students"] = JSONValue(students.map!(s => s.toJsonObj()).array); + obj.object["dates"] = JSONValue(dates); + return obj; + } +} + +/** + * Main endpoint that supplies data for the app's "entries" table, which shows + * all data about all students in a class, usually for a selected week. Here, + * we need to provide a list of students which will be treated as rows by the + * table, and then for each student, an entry object for each date in the + * requested date range. + * Params: + * ctx = The request context. + */ +void getEntries(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + DateRange dateRange = parseDateRangeParams(ctx); + + // First prepare a list of all students, including ones which don't have any entries. + ClassroomComplianceStudent[] students = findAll( + conn, + "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", + &ClassroomComplianceStudent.parse, + cls.id + ); + EntriesTableStudentResponse[] studentObjects = students.map!(s => EntriesTableStudentResponse( + s.id, + s.name, + s.deskNumber, + s.removed, + null, + Optional!double.empty + )).array; + + const entriesQuery = " + SELECT + entry.id, + entry.date, + entry.created_at, + entry.absent, + entry.comment, + entry.phone_compliant, + entry.behavior_rating, + student.id, + student.name, + student.desk_number, + student.removed + FROM classroom_compliance_entry entry + LEFT JOIN classroom_compliance_student student + ON student.id = entry.student_id + WHERE + entry.class_id = ? + AND entry.date >= ? + AND entry.date <= ? + ORDER BY + student.id ASC, + entry.date ASC + "; + PreparedStatement ps = conn.prepareStatement(entriesQuery); + scope(exit) ps.close(); + ps.setUlong(1, cls.id); + ps.setDate(2, dateRange.from); + ps.setDate(3, dateRange.to); + ResultSet rs = ps.executeQuery(); + scope(exit) rs.close(); + foreach (DataSetReader r; rs) { + // Parse the basic data from the query. + const absent = r.getBoolean(4); + Optional!bool phoneCompliant = absent + ? Optional!bool.empty + : Optional!bool.of(r.getBoolean(6)); + Optional!ubyte behaviorRating = absent + ? Optional!ubyte.empty + : Optional!ubyte.of(r.getUbyte(7)); + EntriesTableEntry entryData = EntriesTableEntry( + r.getUlong(1), + r.getDate(2), + r.getUlong(3), + r.getBoolean(4), + r.getString(5), + phoneCompliant, + behaviorRating + ); + ClassroomComplianceStudent student = ClassroomComplianceStudent( + r.getUlong(8), + r.getString(9), + cls.id, + r.getUshort(10), + r.getBoolean(11) + ); + string dateStr = entryData.date.toISOExtString(); + + // Find the student object this entry belongs to, then add it to their list. + bool studentFound = false; + foreach (ref studentObj; studentObjects) { + if (studentObj.id == student.id) { + studentObj.entries[dateStr] = entryData; + studentFound = true; + break; + } + } + if (!studentFound) { + // The student isn't in our list of original students from the + // class, so it's a student who has since moved to another class. + // Their data should still be shown, so add the student here. + studentObjects ~= EntriesTableStudentResponse( + student.id, + student.name, + student.deskNumber, + student.removed, + [dateStr: entryData], + Optional!double.empty + ); + } + } + + // Find scores for each student for this timeframe. + Optional!double[ulong] scores = getScores(conn, cls.id, dateRange); + foreach (studentId, score; scores) { + bool studentFound = false; + foreach (ref studentObj; studentObjects) { + if (studentObj.id == studentId) { + studentObj.score = score; + studentFound = true; + break; + } + } + if (!studentFound) { + throw new Exception("Failed to find student for which a score was calculated."); + } + } + + // Prepare the final response to the client: + EntriesTableResponse response; + Date d = dateRange.from; + while (d <= dateRange.to) { + string dateStr = d.toISOExtString(); + response.dates ~= dateStr; + d += days(1); + } + response.students = studentObjects; + JSONValue responseObj = response.toJsonObj(); + // Go back and add null to any dates any student is missing an entry for. + foreach (ref studentObj; responseObj.object["students"].array) { + foreach (dateStr; response.dates) { + if (dateStr !in studentObj.object["entries"].object) { + studentObj.object["entries"].object[dateStr] = JSONValue(null); + } + } + } + ctx.response.writeBodyString(responseObj.toJSON(), "application/json"); +} + +/** + * Endpoint for the user to save changes to any entries they've edited. The + * user provides a JSON payload containing the updated entries, and we go + * through and perform updates to the database to match the desired state. + * Params: + * ctx = The request context. + */ +void saveEntries(ref HttpRequestContext ctx) { + Connection conn = getDb(); + conn.setAutoCommit(false); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + JSONValue bodyContent = ctx.request.readBodyAsJson(); + try { + foreach (JSONValue studentObj; bodyContent.object["students"].array) { + ulong studentId = studentObj.object["id"].integer(); + JSONValue entries = studentObj.object["entries"]; + foreach (string dateStr, JSONValue entry; entries.object) { + if (entry.isNull) { + deleteEntry(conn, cls.id, studentId, dateStr); + continue; + } + + Optional!ClassroomComplianceEntry existingEntry = findOne( + conn, + "SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", + &ClassroomComplianceEntry.parse, + cls.id, studentId, dateStr + ); + + ulong entryId = entry.object["id"].integer(); + bool creatingNewEntry = entryId == 0; + + if (creatingNewEntry) { + if (!existingEntry.isNull) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Cannot create a new entry when one already exists."); + return; + } + + insertNewEntry(conn, cls.id, studentId, dateStr, entry); + } else { + if (existingEntry.isNull) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Cannot update an entry which doesn't exist."); + return; + } + updateEntry(conn, cls.id, studentId, dateStr, entryId, entry); + } + } + } + conn.commit(); + } catch (HttpStatusException e) { + conn.rollback(); + ctx.response.status = e.status; + ctx.response.writeBodyString(e.message); + } catch (JSONException e) { + conn.rollback(); + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid JSON payload."); + warn(e); + } catch (Exception e) { + conn.rollback(); + ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR; + ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg); + error(e); + } +} + +private void deleteEntry( + Connection conn, + ulong classId, + ulong studentId, + string dateStr +) { + update( + conn, + "DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", + classId, studentId, dateStr + ); + infoF!"Deleted entry for student %s on %s"(studentId, dateStr); +} + +private void insertNewEntry( + Connection conn, + ulong classId, + ulong studentId, + string dateStr, + JSONValue payload +) { + bool absent = payload.object["absent"].boolean; + string comment = payload.object["comment"].str; + if (comment is null) comment = ""; + Optional!bool phoneCompliant = Optional!bool.empty; + Optional!ubyte behaviorRating = Optional!ubyte.empty; + if (!absent) { + phoneCompliant = Optional!bool.of(payload.object["phoneCompliant"].boolean); + behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer); + } + const query = " + INSERT INTO classroom_compliance_entry + (class_id, student_id, date, absent, comment, phone_compliant, behavior_rating) + VALUES (?, ?, ?, ?, ?, ?, ?)"; + + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + ps.setUlong(1, classId); + ps.setUlong(2, studentId); + ps.setString(3, dateStr); + ps.setBoolean(4, absent); + ps.setString(5, comment); + if (absent) { + ps.setNull(6); + ps.setNull(7); + } else { + ps.setBoolean(6, phoneCompliant.value); + ps.setUbyte(7, behaviorRating.value); + } + ps.executeUpdate(); + + infoF!"Created new entry for student %d: %s"(studentId, payload); +} + +private void updateEntry( + Connection conn, + ulong classId, + ulong studentId, + string dateStr, + ulong entryId, + JSONValue obj +) { + bool absent = obj.object["absent"].boolean; + string comment = obj.object["comment"].str; + if (comment is null) comment = ""; + Optional!bool phoneCompliant = Optional!bool.empty; + Optional!ubyte behaviorRating = Optional!ubyte.empty; + if (!absent) { + phoneCompliant = Optional!bool.of(obj.object["phoneCompliant"].boolean); + behaviorRating = Optional!ubyte.of(cast(ubyte) obj.object["behaviorRating"].integer); + } + const query = " + UPDATE classroom_compliance_entry + SET absent = ?, comment = ?, phone_compliant = ?, behavior_rating = ? + WHERE class_id = ? AND student_id = ? AND date = ? AND id = ? + "; + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + ps.setBoolean(1, absent); + ps.setString(2, comment); + if (absent) { + ps.setNull(3); + ps.setNull(4); + } else { + ps.setBoolean(3, phoneCompliant.value); + ps.setUbyte(4, behaviorRating.value); + } + ps.setUlong(5, classId); + ps.setUlong(6, studentId); + ps.setString(7, dateStr); + ps.setUlong(8, entryId); + ps.executeUpdate(); + + infoF!"Updated entry %d"(entryId); +} + +/** + * Gets an associative array that maps student ids to their (optional) scores. + * Scores are calculated based on aggregate statistics from their entries. + * Params: + * conn = The database connection. + * classId = The id of the class to filter by. + * dateRange = The date range to calculate scores for. + * Returns: A map of scores. + */ +Optional!double[ulong] getScores( + Connection conn, + ulong classId, + in DateRange dateRange +) { + Optional!double[ulong] scores; + + const query = " + SELECT + student_id, + COUNT(id) AS entry_count, + SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count, + SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count, + SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good, + SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre, + SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor + FROM classroom_compliance_entry + WHERE + date >= ? + AND date <= ? + AND class_id = ? + GROUP BY student_id + "; + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + ps.setDate(1, dateRange.from); + ps.setDate(2, dateRange.to); + ps.setUlong(3, classId); + foreach (DataSetReader r; ps.executeQuery()) { + ulong studentId = r.getUlong(1); + uint entryCount = r.getUint(2); + uint absenceCount = r.getUint(3); + uint phoneNonComplianceCount = r.getUint(4); + uint behaviorGoodCount = r.getUint(5); + uint behaviorMediocreCount = r.getUint(6); + uint behaviorPoorCount = r.getUint(7); + scores[studentId] = calculateScore( + entryCount, + absenceCount, + phoneNonComplianceCount, + behaviorGoodCount, + behaviorMediocreCount, + behaviorPoorCount + ); + } + return scores; +} + +/** + * Calculates the score for a particular student, using the following formula: + * 1. Ignore all absent days. + * 2. Calculate phone score as compliantDays / total. + * 3. Calculate behavior score as: + * sum(goodBehaviorDays + 0.5 * mediocreBehaviorDays) / total + * 4. Final score is 0.3 * phoneScore + 0.7 * behaviorScore. + * Params: + * entryCount = The number of entries for a student. + * absenceCount = The number of absences the student has. + * phoneNonComplianceCount = The number of times the student was not phone-compliant. + * behaviorGoodCount = The number of days of good behavior. + * behaviorMediocreCount = The number of days of mediocre behavior. + * behaviorPoorCount = The number of days of poor behavior. + * Returns: The score, or an empty optional if there isn't enough data. + */ +private Optional!double calculateScore( + uint entryCount, + uint absenceCount, + uint phoneNonComplianceCount, + uint behaviorGoodCount, + uint behaviorMediocreCount, + uint behaviorPoorCount +) { + if ( + entryCount == 0 + || entryCount <= absenceCount + ) return Optional!double.empty; + + const uint presentCount = entryCount - absenceCount; + + // Phone subscore: + uint phoneCompliantCount; + if (presentCount < phoneNonComplianceCount) { + phoneCompliantCount = 0; + } else { + phoneCompliantCount = presentCount - phoneNonComplianceCount; + } + double phoneScore = phoneCompliantCount / cast(double) presentCount; + + // Behavior subscore: + double behaviorScore = ( + behaviorGoodCount * 1.0 + + behaviorMediocreCount * 0.5 + + behaviorPoorCount * 0 + ) / cast(double) presentCount; + + double score = 0.3 * phoneScore + 0.7 * behaviorScore; + return Optional!double.of(score); +} diff --git a/api/source/api_modules/classroom_compliance/api_student.d b/api/source/api_modules/classroom_compliance/api_student.d new file mode 100644 index 0000000..c8b3930 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/api_student.d @@ -0,0 +1,248 @@ +module api_modules.classroom_compliance.api_student; + +import handy_httpd; +import ddbc; +import std.json; + +import api_modules.auth : User, getUserOrThrow; +import api_modules.classroom_compliance.model; +import api_modules.classroom_compliance.util; +import db; +import data_utils; + +void createStudent(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + struct StudentPayload { + string name; + ushort deskNumber; + bool removed; + } + auto payload = readJsonPayload!(StudentPayload)(ctx); + bool studentExists = recordExists( + conn, + "SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", + payload.name, cls.id + ); + if (studentExists) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("A student with that name already exists in this class."); + return; + } + bool deskAlreadyOccupied = payload.deskNumber != 0 && recordExists( + conn, + "SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?", + cls.id, payload.deskNumber + ); + if (deskAlreadyOccupied) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("There is already a student assigned to that desk number."); + return; + } + ulong studentId = insertOne( + conn, + "INSERT INTO classroom_compliance_student + (name, class_id, desk_number, removed) + VALUES (?, ?, ?, ?) RETURNING id", + payload.name, cls.id, payload.deskNumber, payload.removed + ); + auto student = findOne( + conn, + "SELECT * FROM classroom_compliance_student WHERE id = ?", + &ClassroomComplianceStudent.parse, + studentId + ).orElseThrow(); + writeJsonBody(ctx, student); +} + +void getStudents(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto cls = getClassOrThrow(ctx, conn, user); + auto students = findAll( + conn, + "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", + &ClassroomComplianceStudent.parse, + cls.id + ); + writeJsonBody(ctx, students); +} + +void getStudent(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto student = getStudentOrThrow(ctx, conn, user); + writeJsonBody(ctx, student); +} + +void updateStudent(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto student = getStudentOrThrow(ctx, conn, user); + struct StudentUpdatePayload { + string name; + ushort deskNumber; + bool removed; + } + auto payload = readJsonPayload!(StudentUpdatePayload)(ctx); + // If there is nothing to update, quit. + if ( + payload.name == student.name + && payload.deskNumber == student.deskNumber + && payload.removed == student.removed + ) return; + // Check that the new name doesn't already exist. + bool newNameExists = payload.name != student.name && recordExists( + conn, + "SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", + payload.name, student.classId + ); + if (newNameExists) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("A student with that name already exists in this class."); + return; + } + // Check that if a new desk number is assigned, that it's not already assigned to anyone else. + bool newDeskOccupied = payload.deskNumber != 0 && payload.deskNumber != student.deskNumber && recordExists( + conn, + "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; + } + update( + conn, + "UPDATE classroom_compliance_student SET name = ?, desk_number = ?, removed = ? WHERE id = ?", + payload.name, + payload.deskNumber, + payload.removed, + student.id + ); + auto updatedStudent = findOne( + conn, + "SELECT * FROM classroom_compliance_student WHERE id = ?", + &ClassroomComplianceStudent.parse, + student.id + ).orElseThrow(); + writeJsonBody(ctx, updatedStudent); +} + +void deleteStudent(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto student = getStudentOrThrow(ctx, conn, user); + update( + conn, + "DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?", + student.id, student.classId + ); +} + +void getStudentEntries(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto student = getStudentOrThrow(ctx, conn, user); + auto entries = findAll( + conn, + "SELECT * FROM classroom_compliance_entry WHERE student_id = ? ORDER BY date DESC", + &ClassroomComplianceEntry.parse, + student.id + ); + JSONValue response = JSONValue.emptyArray; + foreach (entry; entries) response.array ~= entry.toJsonObj(); + ctx.response.writeBodyString(response.toJSON(), "application/json"); +} + +void getStudentOverview(ref HttpRequestContext ctx) { + Connection conn = getDb(); + scope(exit) conn.close(); + User user = getUserOrThrow(ctx, conn); + auto student = getStudentOrThrow(ctx, conn, user); + + const ulong entryCount = count( + conn, + "SELECT COUNT(id) FROM classroom_compliance_entry WHERE student_id = ?", + student.id + ); + if (entryCount == 0) { + ctx.response.status = HttpStatus.NOT_FOUND; + ctx.response.writeBodyString("No entries for this student."); + return; + } + const ulong absenceCount = count( + conn, + "SELECT COUNT(id) FROM classroom_compliance_entry WHERE student_id = ? AND absent = true", + student.id + ); + const ulong phoneNoncomplianceCount = count( + conn, + "SELECT COUNT(id) FROM classroom_compliance_entry WHERE phone_compliant = FALSE AND student_id = ?", + student.id + ); + const behaviorCountQuery = " + SELECT COUNT(id) + FROM classroom_compliance_entry + WHERE student_id = ? AND behavior_rating = ? + "; + + const ulong behaviorGoodCount = count(conn, behaviorCountQuery, student.id, 3); + const ulong behaviorMediocreCount = count(conn, behaviorCountQuery, student.id, 2); + const ulong behaviorPoorCount = count(conn, behaviorCountQuery, student.id, 1); + + // Calculate derived statistics. + const ulong attendanceCount = entryCount - absenceCount; + double attendanceRate = attendanceCount / cast(double) entryCount; + double phoneComplianceRate = (attendanceCount - phoneNoncomplianceCount) / cast(double) attendanceCount; + double behaviorScore = ( + behaviorGoodCount * 1.0 + + behaviorMediocreCount * 0.5 + ) / attendanceCount; + + JSONValue response = JSONValue.emptyObject; + response.object["attendanceRate"] = JSONValue(attendanceRate); + response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate); + response.object["behaviorScore"] = JSONValue(behaviorScore); + response.object["entryCount"] = JSONValue(entryCount); + ctx.response.writeBodyString(response.toJSON(), "application/json"); +} + +void moveStudentToOtherClass(ref HttpRequestContext ctx) { + Connection conn = getDb(); + User user = getUserOrThrow(ctx, conn); + auto student = getStudentOrThrow(ctx, conn, user); + struct Payload { + ulong classId; + } + Payload payload = readJsonPayload!(Payload)(ctx); + if (payload.classId == student.classId) { + return; // Quit if the student is already in the desired class. + } + // Check that the desired class exists, and belongs to the user. + bool newClassIdValid = recordExists( + conn, + "SELECT id FROM classroom_compliance_class WHERE user_id = ? and id = ?", + user.id, payload.classId + ); + if (!newClassIdValid) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid class was selected."); + return; + } + // All good, so update the student's class to the desired one, and reset their desk. + update( + conn, + "UPDATE classroom_compliance_student SET class_id = ?, desk_number = 0 WHERE id = ?", + payload.classId, student.id + ); + // We just return 200 OK, no response body. +} diff --git a/api/source/api_modules/classroom_compliance/model.d b/api/source/api_modules/classroom_compliance/model.d new file mode 100644 index 0000000..f2e6613 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/model.d @@ -0,0 +1,100 @@ +module api_modules.classroom_compliance.model; + +import ddbc : DataSetReader; +import handy_httpd.components.optional; +import std.json; +import std.datetime; + +struct ClassroomComplianceClass { + const ulong id; + const ushort number; + const string schoolYear; + const ulong userId; + + static ClassroomComplianceClass parse(DataSetReader r) { + return ClassroomComplianceClass( + r.getUlong(1), + r.getUshort(2), + r.getString(3), + r.getUlong(4) + ); + } +} + +struct ClassroomComplianceStudent { + const ulong id; + const string name; + const ulong classId; + const ushort deskNumber; + const bool removed; + + static ClassroomComplianceStudent parse(DataSetReader r) { + return ClassroomComplianceStudent( + r.getUlong(1), + r.getString(2), + r.getUlong(3), + r.getUshort(4), + r.getBoolean(5) + ); + } +} + +struct ClassroomComplianceEntry { + const ulong id; + const ulong classId; + const ulong studentId; + const Date date; + const ulong createdAt; + const bool absent; + const string comment; + const Optional!bool phoneCompliant; + const Optional!ubyte behaviorRating; + + static ClassroomComplianceEntry parse(DataSetReader r) { + Optional!bool phone = !r.isNull(8) + ? Optional!bool.of(r.getBoolean(8)) + : Optional!bool.empty; + Optional!ubyte behavior = !r.isNull(9) + ? Optional!ubyte.of(r.getUbyte(9)) + : Optional!ubyte.empty; + + return ClassroomComplianceEntry( + r.getUlong(1), + r.getUlong(2), + r.getUlong(3), + r.getDate(4), + r.getUlong(5), + r.getBoolean(6), + r.getString(7), + phone, + behavior + ); + } + + JSONValue toJsonObj() const { + JSONValue obj = JSONValue.emptyObject; + obj.object["id"] = JSONValue(id); + obj.object["classId"] = JSONValue(classId); + obj.object["studentId"] = JSONValue(studentId); + obj.object["date"] = JSONValue(date.toISOExtString()); + obj.object["createdAt"] = JSONValue(createdAt); + obj.object["absent"] = JSONValue(absent); + obj.object["comment"] = JSONValue(comment); + if (absent) { + if (!phoneCompliant.isNull || !behaviorRating.isNull) { + throw new Exception("Illegal entry state! Absent is true while values are not null!"); + } + obj.object["phoneCompliant"] = JSONValue(null); + obj.object["behaviorRating"] = JSONValue(null); + } else { + if (phoneCompliant.isNull || behaviorRating.isNull) { + throw new Exception("Illegal entry state! Absent is false while values are null!"); + } + obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value); + obj.object["behaviorRating"] = JSONValue(behaviorRating.value); + } + return obj; + } +} + + diff --git a/api/source/api_modules/classroom_compliance/package.d b/api/source/api_modules/classroom_compliance/package.d new file mode 100644 index 0000000..5b689a7 --- /dev/null +++ b/api/source/api_modules/classroom_compliance/package.d @@ -0,0 +1,2 @@ +module api_modules.classroom_compliance; + diff --git a/api/source/api_modules/classroom_compliance/util.d b/api/source/api_modules/classroom_compliance/util.d new file mode 100644 index 0000000..c987f9b --- /dev/null +++ b/api/source/api_modules/classroom_compliance/util.d @@ -0,0 +1,102 @@ +module api_modules.classroom_compliance.util; + +import api_modules.classroom_compliance.model; +import api_modules.auth; +import db; + +import ddbc : Connection; +import handy_httpd; +import std.datetime; + +/** + * Gets a Classroom-Compliance class from an HTTP request's path parameters, + * as well as a given user that the class should belong to. + * Params: + * ctx = The request context. + * conn = The database connection to use. + * user = The user who the class belongs to. + * Returns: The class that was found, or a 404 status exception is thrown. + */ +ClassroomComplianceClass getClassOrThrow(in HttpRequestContext ctx, Connection conn, in User user) { + ulong classId = ctx.request.getPathParamAs!ulong("classId"); + return findOne( + conn, + "SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?", + &ClassroomComplianceClass.parse, + user.id, classId + ).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); +} + +/** + * Gets a Classroom-Compliance student from an HTTP request's path parameters, + * as well as a given user that the student should belong to. + * Params: + * ctx = The request context. + * conn = The database connection to use. + * user = The user who the student belongs to. + * Returns: The student that was found, or a 404 status exception is thrown. + */ +ClassroomComplianceStudent getStudentOrThrow(in HttpRequestContext ctx, Connection conn, in User user) { + ulong classId = ctx.request.getPathParamAs!ulong("classId"); + ulong studentId = ctx.request.getPathParamAs!ulong("studentId"); + string query = " + SELECT s.* + FROM classroom_compliance_student s + LEFT JOIN classroom_compliance_class c ON s.class_id = c.id + WHERE s.id = ? AND s.class_id = ? AND c.user_id = ? + "; + return findOne( + conn, + query, + &ClassroomComplianceStudent.parse, + studentId, + classId, + user.id + ).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); +} + +struct DateRange { + Date from; + Date to; +} + +/** + * Helper function to parse a date range from a request's "from" and "to" + * parameters, because this is used commonly for entry-related functions. + * Params: + * ctx = The request context. + * maxDays = The maximum date range length. + * Returns: The date range that was parsed. + */ +DateRange parseDateRangeParams(in HttpRequestContext ctx, uint maxDays = 10) { + 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) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid \"to\" date."); + } + } + if (ctx.request.queryParams.contains("from")) { + try { + fromDate = Date.fromISOExtString(ctx.request.queryParams.getFirst("from").orElse("")); + } catch (DateTimeException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid \"from\" date."); + } + } + if (fromDate > toDate) { + throw new HttpStatusException( + HttpStatus.BAD_REQUEST, + "Invalid date range. From-date must be less than or equal to the to-date." + ); + } + if (toDate - fromDate > days(maxDays)) { + throw new HttpStatusException( + HttpStatus.BAD_REQUEST, + "Date range is too big." + ); + } + return DateRange(fromDate, toDate); +} diff --git a/api/source/app.d b/api/source/app.d index a98711e..ea667ab 100644 --- a/api/source/app.d +++ b/api/source/app.d @@ -1,19 +1,13 @@ import handy_httpd; import handy_httpd.handlers.path_handler; -import d2sqlite3; import std.process; -import db; static import api_modules.auth; -static import api_modules.classroom_compliance; +static import api_modules.classroom_compliance.api; void main() { string env = environment.get("TEACHER_TOOLS_API_ENV", "DEV"); - // Initialize the database on startup. - auto db = getDb(); - db.close(); - ServerConfig config; config.enableWebSockets = false; config.port = 8080; @@ -29,11 +23,10 @@ void main() { config.workerPoolSize = 5; } - PathHandler handler = new PathHandler(); handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint); api_modules.auth.registerApiEndpoints(handler); - api_modules.classroom_compliance.registerApiEndpoints(handler); + api_modules.classroom_compliance.api.registerApiEndpoints(handler); HttpServer server = new HttpServer(handler, config); server.start(); diff --git a/api/source/db.d b/api/source/db.d index f2079db..9daa665 100644 --- a/api/source/db.d +++ b/api/source/db.d @@ -5,56 +5,111 @@ import std.array; import std.typecons; import std.conv; -import d2sqlite3; +import ddbc; import slf4d; import handy_httpd.components.optional; -struct Column { - const string name; +private DataSource dataSource; + +static this() { + import std.process : environment; + string username = environment.get("TEACHER_TOOLS_DB_USERNAME", "teacher-tools-dev"); + string password = environment.get("TEACHER_TOOLS_DB_PASSWORD", "testpass"); + string dbUrl = environment.get("TEACHER_TOOLS_DB_URL", "postgresql://localhost:5432/teacher-tools-dev"); + string connectionStr = dbUrl ~ "?user=" ~ username ~ ",password=" ~ password; + + dataSource = createDataSource(connectionStr); } -Database getDb() { - import std.file; - bool shouldInitDb = !exists("teacher-tools.db"); - int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; - if (d2sqlite3.threadSafe()) { - flags |= SQLITE_OPEN_NOMUTEX; +Connection getDb() { + return dataSource.getConnection(); +} + +T[] findAll(T, Args...)( + Connection conn, + string query, + T function(DataSetReader) parser, + Args args +) { + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + bindAllArgs(ps, args); + ResultSet rs = ps.executeQuery(); + scope(exit) rs.close(); + Appender!(T[]) app; + foreach (row; rs) { + app ~= parser(row); } - Database db = Database("teacher-tools.db", flags); - db.execute("PRAGMA foreign_keys=ON"); - if (shouldInitDb) { - const string authSchema = import("schema/auth.sql"); - const string classroomComplianceSchema = import("schema/classroom_compliance.sql"); - db.run(authSchema); - db.run(classroomComplianceSchema); + return app[]; +} - import sample_data; - insertSampleData(db); - - info("Initialized database schema."); +Optional!T findOne(T, Args...)( + Connection conn, + string query, + T function(DataSetReader) parser, + Args args +) { + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + bindAllArgs(ps, args); + ResultSet rs = ps.executeQuery(); + scope(exit) rs.close(); + if (rs.next()) { + return Optional!T.of(parser(rs)); } - return db; + return Optional!T.empty; } -T[] findAll(T, Args...)(Database db, string query, Args args) { - Statement stmt = db.prepare(query); - stmt.bindAll(args); - ResultRange result = stmt.execute(); - return result.map!(row => parseRow!T(row)).array; +ulong count(Args...)(Connection conn, string query, Args args) { + return findOne(conn, query, r => r.getUlong(1), args).orElse(0); } -Optional!T findOne(T, Args...)(Database db, string query, Args args) { - Statement stmt = db.prepare(query); - stmt.bindAll(args); - ResultRange result = stmt.execute(); - if (result.empty) return Optional!T.empty; - return Optional!T.of(parseRow!T(result.front)); +bool recordExists(Args...)(Connection conn, string query, Args args) { + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + bindAllArgs(ps, args); + ResultSet rs = ps.executeQuery(); + scope(exit) rs.close(); + return rs.next(); } -bool canFind(Args...)(Database db, string query, Args args) { - Statement stmt = db.prepare(query); - stmt.bindAll(args); - return !stmt.execute().empty; +ulong insertOne(Args...)(Connection conn, string query, Args args) { + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + bindAllArgs(ps, args); + import std.variant; + Variant insertedId; + int affectedRows = ps.executeUpdate(insertedId); + if (affectedRows != 1) { + throw new Exception("Failed to insert exactly 1 row."); + } + return insertedId.coerce!ulong; +} + +int update(Args...)(Connection conn, string query, Args args) { + PreparedStatement ps = conn.prepareStatement(query); + scope(exit) ps.close(); + bindAllArgs(ps, args); + return ps.executeUpdate(); +} + +void bindAllArgs(Args...)(PreparedStatement ps, Args args) { + int idx; + static foreach (i, arg; args) { + idx = i + 1; + static if (is(typeof(arg) == string)) ps.setString(idx, arg); + else static if (is(typeof(arg) == const(string))) ps.setString(idx, arg); + else static if (is(typeof(arg) == bool)) ps.setBoolean(idx, arg); + else static if (is(typeof(arg) == ulong)) ps.setUlong(idx, arg); + else static if (is(typeof(arg) == const(ulong))) ps.setUlong(idx, arg); + else static if (is(typeof(arg) == ushort)) ps.setUshort(idx, arg); + else static if (is(typeof(arg) == const(ushort))) ps.setUshort(idx, arg); + else static if (is(typeof(arg) == int)) ps.setInt(idx, arg); + else static if (is(typeof(arg) == const(int))) ps.setInt(idx, arg); + else static if (is(typeof(arg) == uint)) ps.setUint(idx, arg); + else static if (is(typeof(arg) == const(uint))) ps.setUint(idx, arg); + else static assert(false, "Unsupported argument type: " ~ (typeof(arg).stringof)); + } } private string toSnakeCase(string camelCase) { @@ -104,7 +159,7 @@ private string getArgsStr(T)() { return argsStr; } -T parseRow(T)(Row row) { - mixin("T t = T(" ~ getArgsStr!T ~ ");"); - return t; -} +// T parseRow(T)(Row row) { +// mixin("T t = T(" ~ getArgsStr!T ~ ");"); +// return t; +// } diff --git a/api/source/sample_data.d b/api/source/sample_data.d index b83506c..66b652e 100644 --- a/api/source/sample_data.d +++ b/api/source/sample_data.d @@ -1,8 +1,8 @@ module sample_data; +import ddbc; import db; import data_utils; -import d2sqlite3; import std.random; import std.algorithm; @@ -32,21 +32,27 @@ private const STUDENT_NAMES = [ "Cindy" ]; -void insertSampleData(ref Database db) { - db.begin(); - addUser(db, "sample-user-A", "test", false, false); - addUser(db, "sample-user-B", "test", true, false); - addUser(db, "sample-user-C", "test", false, false); - addUser(db, "sample-user-D", "test", false, false); +void insertSampleData() { + Connection conn = getDb(); + conn.setAutoCommit(false); + scope(exit) { + conn.commit(); + conn.close(); + } - ulong adminUserId = addUser(db, "test", "test", false, true); - ulong normalUserId = addUser(db, "test2", "test", false, false); + addUser(conn, "sample-user-A", "test", false, false); + addUser(conn, "sample-user-B", "test", true, false); + addUser(conn, "sample-user-C", "test", false, false); + addUser(conn, "sample-user-D", "test", false, false); + + ulong adminUserId = addUser(conn, "test", "test", false, true); + ulong normalUserId = addUser(conn, "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); + ulong classId = addClass(conn, "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); @@ -57,7 +63,7 @@ void insertSampleData(ref Database db) { if (classHasAssignedDesks) { assignedDeskNumber = deskNumber++; } - ulong studentId = addStudent(db, name, classId, assignedDeskNumber, removed); + ulong studentId = addStudent(conn, name, classId, assignedDeskNumber, removed); // Add entries for the last N days for (int n = 0; n < 30; n++) { @@ -74,36 +80,42 @@ void insertSampleData(ref Database db) { behaviorRating = 3; } } - addEntry(db, classId, studentId, entryDate, absent, phoneCompliant, behaviorRating); + addEntry(conn, classId, studentId, entryDate, absent, phoneCompliant, behaviorRating); } } } - 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 (?, ?, ?, ?, ?)"; +ulong addUser(Connection conn, string username, string password, bool locked, bool admin) { 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(); + return insertOne( + conn, + "INSERT INTO auth_user (username, password_hash, is_locked, is_admin) VALUES (?, ?, ?, ?) RETURNING id", + username, passwordHash, locked, admin + ); } -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 addClass(Connection conn, string schoolYear, ushort number, ulong userId) { + return insertOne( + conn, + "INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?) RETURNING id", + number, schoolYear, userId + ); } -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(); +ulong addStudent(Connection conn, string name, ulong classId, ushort deskNumber, bool removed) { + return insertOne( + conn, + "INSERT INTO classroom_compliance_student + (name, class_id, desk_number, removed) + VALUES (?, ?, ?, ?) RETURNING id", + name, classId, deskNumber, removed + ); } void addEntry( - ref Database db, + Connection conn, ulong classId, ulong studentId, Date date, @@ -113,24 +125,21 @@ void addEntry( ) { const entryQuery = " INSERT INTO classroom_compliance_entry - (class_id, student_id, date, created_at, absent, comment) - VALUES (?, ?, ?, ?, ?, ?)"; - db.execute( - entryQuery, - classId, - studentId, - date.toISOExtString(), - getUnixTimestampMillis(), - absent, - "Sample comment." - ); - 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) - VALUES (?, ?)"; - db.execute(behaviorQuery, entryId, behaviorRating); + (class_id, student_id, date, absent, comment, phone_compliant, behavior_rating) + VALUES (?, ?, ?, ?, ?, ?, ?)"; + PreparedStatement ps = conn.prepareStatement(entryQuery); + scope(exit) ps.close(); + ps.setUlong(1, classId); + ps.setUlong(2, studentId); + ps.setDate(3, date); + ps.setBoolean(4, absent); + ps.setString(5, "Testing comment"); + if (absent) { + ps.setNull(6); + ps.setNull(7); + } else { + ps.setBoolean(6, phoneCompliant); + ps.setUint(7, behaviorRating); + } + ps.executeUpdate(); } diff --git a/app/src/api/classroom_compliance.ts b/app/src/api/classroom_compliance.ts index da210d5..8e0ed12 100644 --- a/app/src/api/classroom_compliance.ts +++ b/app/src/api/classroom_compliance.ts @@ -24,21 +24,13 @@ export interface Student { removed: boolean } -export interface EntryPhone { - compliant: boolean -} - -export interface EntryBehavior { - rating: number -} - export interface Entry { id: number date: string createdAt: number absent: boolean - phone: EntryPhone | null - behavior: EntryBehavior | null + phoneCompliant: boolean | null + behaviorRating: number | null comment: string } @@ -48,8 +40,8 @@ export function getDefaultEntry(dateStr: string): Entry { date: dateStr, createdAt: Date.now(), absent: false, - phone: { compliant: true }, - behavior: { rating: 3 }, + phoneCompliant: true, + behaviorRating: 3, comment: '', } } diff --git a/app/src/apps/classroom_compliance/ClassView.vue b/app/src/apps/classroom_compliance/ClassView.vue index 7a03211..882bf85 100644 --- a/app/src/apps/classroom_compliance/ClassView.vue +++ b/app/src/apps/classroom_compliance/ClassView.vue @@ -15,13 +15,15 @@ const cls: Ref = ref(null) const apiClient = new ClassroomComplianceAPIClient(authStore) const deleteClassDialog = useTemplateRef('deleteClassDialog') -onMounted(async () => { +onMounted(() => { const idNumber = parseInt(props.id, 10) - cls.value = await apiClient.getClass(idNumber).handleErrorsWithAlert() - if (!cls.value) { - await router.back() - return - } + apiClient.getClass(idNumber).handleErrorsWithAlert().then(result => { + if (result) { + cls.value = result + } else { + router.back(); + } + }) }) async function deleteThisClass() { diff --git a/app/src/apps/classroom_compliance/ClassesView.vue b/app/src/apps/classroom_compliance/ClassesView.vue index a805fa6..d8b9e77 100644 --- a/app/src/apps/classroom_compliance/ClassesView.vue +++ b/app/src/apps/classroom_compliance/ClassesView.vue @@ -12,7 +12,9 @@ const router = useRouter() const apiClient = new ClassroomComplianceAPIClient(authStore) onMounted(async () => { - classes.value = (await apiClient.getClasses().handleErrorsWithAlert()) ?? [] + apiClient.getClasses().handleErrorsWithAlert().then(result => { + if (result) classes.value = result + }) })