Refactored to use PostgreSQL.
This commit is contained in:
		
							parent
							
								
									ae97fa89e9
								
							
						
					
					
						commit
						b66d0c4770
					
				|  | @ -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: actions/setup-dlang@v2 | ||||
|         with: | ||||
|           compiler: ldc-latest | ||||
|       - name: Build | ||||
|         working-directory: ./api | ||||
|         run: dub -q build --build=release | ||||
|  | @ -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 | ||||
|  | @ -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 | ||||
|  | @ -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" | ||||
| 	} | ||||
| } | ||||
|  | @ -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" | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| ); | ||||
|  |  | |||
|  | @ -1,40 +1,40 @@ | |||
| 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 | ||||
| ); | ||||
| 
 | ||||
| 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) | ||||
| 	) | ||||
| ); | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
|  |  | |||
|  | @ -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"); | ||||
| } | ||||
|  | @ -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); | ||||
| } | ||||
|  | @ -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(); | ||||
| } | ||||
|  | @ -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); | ||||
| } | ||||
|  | @ -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.
 | ||||
| } | ||||
|  | @ -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; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
|  | @ -0,0 +1,2 @@ | |||
| module api_modules.classroom_compliance; | ||||
| 
 | ||||
|  | @ -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); | ||||
| } | ||||
|  | @ -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(); | ||||
|  |  | |||
							
								
								
									
										135
									
								
								api/source/db.d
								
								
								
								
							
							
						
						
									
										135
									
								
								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;
 | ||||
| // }
 | ||||
|  |  | |||
|  | @ -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(); | ||||
| } | ||||
|  |  | |||
|  | @ -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: '', | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -15,13 +15,15 @@ const cls: Ref<Class | null> = 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() { | ||||
|  |  | |||
|  | @ -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 | ||||
|   }) | ||||
| }) | ||||
| </script> | ||||
| <template> | ||||
|  |  | |||
|  | @ -38,21 +38,21 @@ function toggleAbsence() { | |||
|     model.value.absent = !model.value.absent | ||||
|     if (model.value.absent) { | ||||
|       // Remove additional data if student is absent. | ||||
|       model.value.phone = null | ||||
|       model.value.behavior = null | ||||
|       model.value.phoneCompliant = null | ||||
|       model.value.behaviorRating = null | ||||
|     } else { | ||||
|       // Populate default additional data if student is no longer absent. | ||||
|       model.value.phone = { compliant: true } | ||||
|       model.value.behavior = { rating: 3 } | ||||
|       model.value.phoneCompliant = true | ||||
|       model.value.behaviorRating = 3 | ||||
|       // If we have an initial entry known, restore data from that. | ||||
|       if (initialEntryJson.value) { | ||||
|         const initialEntry = JSON.parse(initialEntryJson.value) as Entry | ||||
|         if (initialEntry.absent) return | ||||
|         if (initialEntry.phone) { | ||||
|           model.value.phone = { compliant: initialEntry.phone?.compliant } | ||||
|         if (initialEntry.phoneCompliant) { | ||||
|           model.value.phoneCompliant = initialEntry.phoneCompliant | ||||
|         } | ||||
|         if (initialEntry.behavior) { | ||||
|           model.value.behavior = { rating: initialEntry.behavior.rating } | ||||
|         if (initialEntry.behaviorRating) { | ||||
|           model.value.behaviorRating = initialEntry.behaviorRating | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | @ -60,16 +60,16 @@ function toggleAbsence() { | |||
| } | ||||
| 
 | ||||
| function togglePhoneCompliance() { | ||||
|   if (model.value && model.value.phone) { | ||||
|     model.value.phone.compliant = !model.value.phone.compliant | ||||
|   if (model.value && model.value.phoneCompliant !== null) { | ||||
|     model.value.phoneCompliant = !model.value.phoneCompliant | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function toggleBehaviorRating() { | ||||
|   if (model.value && model.value.behavior) { | ||||
|     model.value.behavior.rating = model.value.behavior.rating - 1 | ||||
|     if (model.value.behavior.rating < 1) { | ||||
|       model.value.behavior.rating = 3 | ||||
|   if (model.value && model.value.behaviorRating) { | ||||
|     model.value.behaviorRating = model.value.behaviorRating - 1 | ||||
|     if (model.value.behaviorRating < 1) { | ||||
|       model.value.behaviorRating = 3 | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | @ -111,13 +111,13 @@ function addEntry() { | |||
|           <span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span> | ||||
|         </div> | ||||
|         <div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent"> | ||||
|           <span v-if="model.phone?.compliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span> | ||||
|           <span v-if="!model.phone?.compliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span> | ||||
|           <span v-if="model.phoneCompliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span> | ||||
|           <span v-if="!model.phoneCompliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span> | ||||
|         </div> | ||||
|         <div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent"> | ||||
|           <span v-if="model.behavior?.rating === 3" title="Good Behavior">{{ EMOJI_BEHAVIOR_GOOD }}</span> | ||||
|           <span v-if="model.behavior?.rating === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span> | ||||
|           <span v-if="model.behavior?.rating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span> | ||||
|           <span v-if="model.behaviorRating === 3" title="Good Behavior">{{ EMOJI_BEHAVIOR_GOOD }}</span> | ||||
|           <span v-if="model.behaviorRating === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span> | ||||
|           <span v-if="model.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span> | ||||
|         </div> | ||||
|         <div class="status-item" @click="showCommentEditor"> | ||||
|           <span v-if="hasComment" | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue