Compare commits
No commits in common. "620fd94f8f1ee33564f7490c9ac1bb44a210615a" and "ae97fa89e9d5c62fb303c74bc28d3cda04875e38" have entirely different histories.
620fd94f8f
...
ae97fa89e9
|
@ -1,20 +0,0 @@
|
||||||
name: Build and Test API
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- 'api/**'
|
|
||||||
- '.gitea/workflows/test-api.yaml'
|
|
||||||
pull_request:
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
jobs:
|
|
||||||
Build-and-test-API:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Setup DLang
|
|
||||||
uses: dlang-community/setup-dlang@v2
|
|
||||||
with:
|
|
||||||
compiler: ldc-latest
|
|
||||||
- name: Build
|
|
||||||
working-directory: ./api
|
|
||||||
run: dub -q build --build=release
|
|
|
@ -1,31 +0,0 @@
|
||||||
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
|
|
|
@ -1,19 +0,0 @@
|
||||||
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,7 +5,8 @@
|
||||||
"copyright": "Copyright © 2024, Andrew Lalis",
|
"copyright": "Copyright © 2024, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asdf": "~>0.7.17",
|
"asdf": "~>0.7.17",
|
||||||
"ddbc": "~>0.6.2",
|
"botan": "~>1.13.6",
|
||||||
|
"d2sqlite3": "~>1.0.0",
|
||||||
"handy-httpd": "~>8.4.3"
|
"handy-httpd": "~>8.4.3"
|
||||||
},
|
},
|
||||||
"description": "A minimal D application.",
|
"description": "A minimal D application.",
|
||||||
|
@ -15,6 +16,6 @@
|
||||||
"."
|
"."
|
||||||
],
|
],
|
||||||
"subConfigurations": {
|
"subConfigurations": {
|
||||||
"ddbc": "PGSQL"
|
"d2sqlite3": "all-included"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,20 +2,17 @@
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
"asdf": "0.7.17",
|
"asdf": "0.7.17",
|
||||||
"d-unit": "0.10.2",
|
"botan": "1.13.6",
|
||||||
"ddbc": "0.6.2",
|
"botan-math": "1.0.4",
|
||||||
"derelict-pq": "2.2.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"derelict-util": "2.0.6",
|
"handy-httpd": "8.4.3",
|
||||||
"handy-httpd": "8.4.5",
|
|
||||||
"httparsed": "1.2.1",
|
"httparsed": "1.2.1",
|
||||||
"mir-algorithm": "3.22.3",
|
"memutils": "1.0.10",
|
||||||
|
"mir-algorithm": "3.22.1",
|
||||||
"mir-core": "1.7.1",
|
"mir-core": "1.7.1",
|
||||||
"mysql-native": "3.1.0",
|
|
||||||
"odbc": "1.0.0",
|
|
||||||
"path-matcher": "1.2.0",
|
"path-matcher": "1.2.0",
|
||||||
"silly": "1.1.1",
|
"silly": "1.1.1",
|
||||||
"slf4d": "3.0.1",
|
"slf4d": "3.0.1",
|
||||||
"streams": "3.5.0",
|
"streams": "3.5.0"
|
||||||
"undead": "1.1.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
CREATE TABLE auth_user (
|
CREATE TABLE user (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
username VARCHAR(64) NOT NULL UNIQUE
|
username TEXT NOT NULL UNIQUE,
|
||||||
CONSTRAINT username_check CHECK (LENGTH(username) >= 3),
|
password_hash TEXT NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL
|
created_at INTEGER NOT NULL,
|
||||||
CONSTRAINT password_check CHECK (LENGTH(password_hash) >= 32),
|
is_locked INTEGER NOT NULL,
|
||||||
created_at BIGINT NOT NULL
|
is_admin INTEGER NOT NULL
|
||||||
DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
|
|
||||||
is_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,44 +1,40 @@
|
||||||
CREATE TABLE classroom_compliance_class (
|
CREATE TABLE classroom_compliance_class (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
number INT NOT NULL
|
number INTEGER NOT NULL,
|
||||||
CONSTRAINT class_number_check CHECK (number > 0),
|
school_year TEXT NOT NULL,
|
||||||
school_year VARCHAR(9) NOT NULL,
|
user_id INTEGER NOT NULL REFERENCES user(id)
|
||||||
user_id BIGINT NOT NULL
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
REFERENCES auth_user(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
|
||||||
CONSTRAINT unique_class_numbers_per_school_year
|
|
||||||
UNIQUE(number, school_year, user_id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE classroom_compliance_student (
|
CREATE TABLE classroom_compliance_student (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name TEXT NOT NULL,
|
||||||
class_id BIGINT NOT NULL
|
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||||
REFERENCES classroom_compliance_class(id)
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
desk_number INTEGER NOT NULL DEFAULT 0,
|
||||||
desk_number INT NOT NULL DEFAULT 0,
|
removed INTEGER NOT NULL DEFAULT 0
|
||||||
removed BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE classroom_compliance_entry (
|
CREATE TABLE classroom_compliance_entry (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
class_id BIGINT NOT NULL
|
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||||
REFERENCES classroom_compliance_class(id)
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id)
|
||||||
student_id BIGINT NOT NULL
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
REFERENCES classroom_compliance_student(id)
|
date TEXT NOT NULL,
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
created_at INTEGER NOT NULL,
|
||||||
date DATE NOT NULL,
|
absent INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at BIGINT NOT NULL
|
comment TEXT NOT NULL DEFAULT ''
|
||||||
DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
|
);
|
||||||
absent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
comment VARCHAR(2000) NOT NULL DEFAULT '',
|
CREATE TABLE classroom_compliance_entry_phone (
|
||||||
phone_compliant BOOLEAN NULL DEFAULT NULL,
|
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
|
||||||
behavior_rating INT NULL DEFAULT NULL,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT absence_nulls_check CHECK (
|
compliant INTEGER NOT NULL DEFAULT 1
|
||||||
(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)
|
|
||||||
),
|
CREATE TABLE classroom_compliance_entry_behavior (
|
||||||
CONSTRAINT unique_entry_per_date
|
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
|
||||||
UNIQUE(class_id, student_id, date)
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
rating INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,10 +4,10 @@ import handy_httpd;
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
import handy_httpd.handlers.path_handler;
|
import handy_httpd.handlers.path_handler;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
|
import d2sqlite3;
|
||||||
import std.algorithm : map;
|
import std.algorithm : map;
|
||||||
import std.array : array;
|
import std.array : array;
|
||||||
import std.json;
|
import std.json;
|
||||||
import ddbc;
|
|
||||||
|
|
||||||
import db;
|
import db;
|
||||||
import data_utils;
|
import data_utils;
|
||||||
|
@ -19,17 +19,6 @@ struct User {
|
||||||
const ulong createdAt;
|
const ulong createdAt;
|
||||||
const bool isLocked;
|
const bool isLocked;
|
||||||
const bool isAdmin;
|
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 {
|
private struct UserResponse {
|
||||||
|
@ -53,7 +42,7 @@ private string encodePassword(string password) {
|
||||||
return toHexString(sha256Of(password)).idup;
|
return toHexString(sha256Of(password)).idup;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connection conn) {
|
private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, ref Database db) {
|
||||||
import std.base64;
|
import std.base64;
|
||||||
import std.string : startsWith;
|
import std.string : startsWith;
|
||||||
import std.digest.sha;
|
import std.digest.sha;
|
||||||
|
@ -68,12 +57,7 @@ private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connectio
|
||||||
size_t idx = countUntil(decoded, ':');
|
size_t idx = countUntil(decoded, ':');
|
||||||
string username = decoded[0..idx];
|
string username = decoded[0..idx];
|
||||||
auto passwordHash = encodePassword(decoded[idx+1 .. $]);
|
auto passwordHash = encodePassword(decoded[idx+1 .. $]);
|
||||||
Optional!User optUser = findOne(
|
Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username);
|
||||||
conn,
|
|
||||||
"SELECT * FROM auth_user WHERE username = ?",
|
|
||||||
&User.parse,
|
|
||||||
username
|
|
||||||
);
|
|
||||||
if ( // Reject the user's authentication, even if they exist, if:
|
if ( // Reject the user's authentication, even if they exist, if:
|
||||||
!optUser.isNull &&
|
!optUser.isNull &&
|
||||||
(
|
(
|
||||||
|
@ -91,11 +75,11 @@ private Optional!User getUserFromBasicAuth(ref HttpRequestContext ctx, Connectio
|
||||||
* authentication header.
|
* authentication header.
|
||||||
* Params:
|
* Params:
|
||||||
* ctx = The request context.
|
* ctx = The request context.
|
||||||
* conn = The database connection.
|
* db = The database to query.
|
||||||
* Returns: The user that made the request. Otherwise, a 401 is thrown.
|
* Returns: The user that made the request. Otherwise, a 401 is thrown.
|
||||||
*/
|
*/
|
||||||
User getUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
|
User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
|
||||||
Optional!User optUser = getUserFromBasicAuth(ctx, conn);
|
Optional!User optUser = getUserFromBasicAuth(ctx, db);
|
||||||
if (optUser.isNull) {
|
if (optUser.isNull) {
|
||||||
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
||||||
}
|
}
|
||||||
|
@ -106,11 +90,11 @@ User getUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
|
||||||
* Similar to `getUserOrThrow`, but throws a 403 if the user isn't an admin.
|
* Similar to `getUserOrThrow`, but throws a 403 if the user isn't an admin.
|
||||||
* Params:
|
* Params:
|
||||||
* ctx = The request context.
|
* ctx = The request context.
|
||||||
* conn = The database connection.
|
* db = The database to query.
|
||||||
* Returns: The user that made the request.
|
* Returns: The user that made the request.
|
||||||
*/
|
*/
|
||||||
User getAdminUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
|
User getAdminUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
|
||||||
User user = getUserOrThrow(ctx, conn);
|
User user = getUserOrThrow(ctx, db);
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource.");
|
throw new HttpStatusException(HttpStatus.FORBIDDEN, "Forbidden from accessing this resource.");
|
||||||
}
|
}
|
||||||
|
@ -118,9 +102,8 @@ User getAdminUserOrThrow(ref HttpRequestContext ctx, Connection conn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loginEndpoint(ref HttpRequestContext ctx) {
|
private void loginEndpoint(ref HttpRequestContext ctx) {
|
||||||
Connection conn = getDb();
|
Database db = getDb();
|
||||||
scope(exit) conn.close();
|
Optional!User optUser = getUserFromBasicAuth(ctx, db);
|
||||||
Optional!User optUser = getUserFromBasicAuth(ctx, conn);
|
|
||||||
if (optUser.isNull) {
|
if (optUser.isNull) {
|
||||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||||
ctx.response.writeBodyString("Invalid credentials.");
|
ctx.response.writeBodyString("Invalid credentials.");
|
||||||
|
@ -137,9 +120,8 @@ private void loginEndpoint(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void usersAdminEndpoint(ref HttpRequestContext ctx) {
|
private void usersAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
Connection conn = getDb();
|
Database db = getDb();
|
||||||
scope(exit) conn.close();
|
User user = getAdminUserOrThrow(ctx, db);
|
||||||
User user = getAdminUserOrThrow(ctx, conn);
|
|
||||||
uint page = ctx.request.getParamAs!uint("page", 0);
|
uint page = ctx.request.getParamAs!uint("page", 0);
|
||||||
uint pageSize = ctx.request.getParamAs!uint("size", 30);
|
uint pageSize = ctx.request.getParamAs!uint("size", 30);
|
||||||
if (page < 0) page = 0;
|
if (page < 0) page = 0;
|
||||||
|
@ -147,22 +129,16 @@ private void usersAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
if (pageSize > 100) pageSize = 100;
|
if (pageSize > 100) pageSize = 100;
|
||||||
uint offset = page * pageSize;
|
uint offset = page * pageSize;
|
||||||
|
|
||||||
const query = "SELECT * FROM auth_user ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
const query = "SELECT * FROM user ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
||||||
UserResponse[] users = findAll(
|
UserResponse[] users = findAll!(User)(db, query, pageSize, offset)
|
||||||
conn,
|
|
||||||
query,
|
|
||||||
&User.parse,
|
|
||||||
pageSize, offset
|
|
||||||
)
|
|
||||||
.map!(u => UserResponse(u.id, u.username, u.createdAt, u.isLocked, u.isAdmin))
|
.map!(u => UserResponse(u.id, u.username, u.createdAt, u.isLocked, u.isAdmin))
|
||||||
.array;
|
.array;
|
||||||
writeJsonBody(ctx, users);
|
writeJsonBody(ctx, users);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
|
private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
Connection conn = getDb();
|
Database db = getDb();
|
||||||
scope(exit) conn.close();
|
User user = getAdminUserOrThrow(ctx, db);
|
||||||
User user = getAdminUserOrThrow(ctx, conn);
|
|
||||||
struct Payload {
|
struct Payload {
|
||||||
string username;
|
string username;
|
||||||
string password;
|
string password;
|
||||||
|
@ -171,20 +147,17 @@ private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
// TODO: Validate data
|
// TODO: Validate data
|
||||||
string passwordHash = encodePassword(payload.password);
|
string passwordHash = encodePassword(payload.password);
|
||||||
const query = "
|
const query = "
|
||||||
INSERT INTO auth_user (
|
INSERT INTO user (
|
||||||
username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
is_locked,
|
created_at,
|
||||||
is_admin
|
is_locked,
|
||||||
) VALUES (?, ?, ?, ?)
|
is_admin
|
||||||
RETURNING id
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
";
|
";
|
||||||
ulong newUserId = insertOne(
|
db.execute(query, payload.username, passwordHash, getUnixTimestampMillis(), false, false);
|
||||||
conn,
|
ulong newUserId = db.lastInsertRowid();
|
||||||
query,
|
User newUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", newUserId).orElseThrow();
|
||||||
payload.username, passwordHash, false, false
|
|
||||||
);
|
|
||||||
User newUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, newUserId).orElseThrow();
|
|
||||||
writeJsonBody(ctx, UserResponse(
|
writeJsonBody(ctx, UserResponse(
|
||||||
newUser.id,
|
newUser.id,
|
||||||
newUser.username,
|
newUser.username,
|
||||||
|
@ -195,28 +168,20 @@ private void createUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) {
|
private void deleteUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
Connection conn = getDb();
|
Database db = getDb();
|
||||||
scope(exit) conn.close();
|
User user = getAdminUserOrThrow(ctx, db);
|
||||||
User user = getAdminUserOrThrow(ctx, conn);
|
|
||||||
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
|
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
|
||||||
Optional!User targetUser = findOne(
|
Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId);
|
||||||
conn,
|
|
||||||
"SELECT * FROM auth_user WHERE id = ?",
|
|
||||||
&User.parse,
|
|
||||||
targetUserId
|
|
||||||
);
|
|
||||||
if (!targetUser.isNull) {
|
if (!targetUser.isNull) {
|
||||||
update(conn, "DELETE FROM auth_user WHERE id = ?", targetUserId);
|
db.execute("DELETE FROM user WHERE id = ?", targetUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
|
private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
Connection conn = getDb();
|
Database db = getDb();
|
||||||
scope(exit) conn.close();
|
User user = getAdminUserOrThrow(ctx, db);
|
||||||
conn.setAutoCommit(false);
|
|
||||||
User user = getAdminUserOrThrow(ctx, conn);
|
|
||||||
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
|
ulong targetUserId = ctx.request.getPathParamAs!ulong("userId");
|
||||||
Optional!User targetUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId);
|
Optional!User targetUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId);
|
||||||
if (targetUser.isNull) {
|
if (targetUser.isNull) {
|
||||||
ctx.response.status = HttpStatus.NOT_FOUND;
|
ctx.response.status = HttpStatus.NOT_FOUND;
|
||||||
ctx.response.writeBodyString("User not found.");
|
ctx.response.writeBodyString("User not found.");
|
||||||
|
@ -228,6 +193,7 @@ private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
ctx.response.writeBodyString("Expected JSON object with user properties.");
|
ctx.response.writeBodyString("Expected JSON object with user properties.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
db.begin();
|
||||||
try {
|
try {
|
||||||
if ("username" in payload.object) {
|
if ("username" in payload.object) {
|
||||||
string newUsername = payload.object["username"].str;
|
string newUsername = payload.object["username"].str;
|
||||||
|
@ -235,34 +201,33 @@ private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
if (newUsername.length < 3 || newUsername.length > 32) {
|
if (newUsername.length < 3 || newUsername.length > 32) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
ctx.response.writeBodyString("Invalid username.");
|
ctx.response.writeBodyString("Invalid username.");
|
||||||
conn.rollback();
|
db.rollback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (recordExists(conn, "SELECT id FROM auth_user WHERE username = ?", newUsername)) {
|
if (canFind(db, "SELECT id FROM user WHERE username = ?", newUsername)) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
ctx.response.writeBodyString("Username already taken.");
|
ctx.response.writeBodyString("Username already taken.");
|
||||||
conn.rollback();
|
db.rollback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
update(conn, "UPDATE auth_user SET username = ? WHERE id = ?", newUsername, targetUserId);
|
db.execute("UPDATE user SET username = ? WHERE id = ?", newUsername, targetUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ("password" in payload.object) {
|
if ("password" in payload.object) {
|
||||||
string rawPassword = payload.object["password"].str;
|
string rawPassword = payload.object["password"].str;
|
||||||
string newPasswordHash = encodePassword(rawPassword);
|
string newPasswordHash = encodePassword(rawPassword);
|
||||||
if (newPasswordHash != targetUser.value.passwordHash) {
|
if (newPasswordHash != targetUser.value.passwordHash) {
|
||||||
update(conn, "UPDATE auth_user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId);
|
db.execute("UPDATE user SET password_hash = ? WHERE id = ?", newPasswordHash, targetUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ("isLocked" in payload.object) {
|
if ("isLocked" in payload.object) {
|
||||||
bool newIsLocked = payload.object["isLocked"].boolean;
|
bool newIsLocked = payload.object["isLocked"].boolean;
|
||||||
if (newIsLocked != targetUser.value.isLocked) {
|
if (newIsLocked != targetUser.value.isLocked) {
|
||||||
update(conn, "UPDATE auth_user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId);
|
db.execute("UPDATE user SET is_locked = ? WHERE id = ?", newIsLocked, targetUserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
conn.commit();
|
db.commit();
|
||||||
User updatedUser = findOne(conn, "SELECT * FROM auth_user WHERE id = ?", &User.parse, targetUserId)
|
User updatedUser = findOne!(User)(db, "SELECT * FROM user WHERE id = ?", targetUserId).orElseThrow();
|
||||||
.orElseThrow();
|
|
||||||
writeJsonBody(ctx, UserResponse(
|
writeJsonBody(ctx, UserResponse(
|
||||||
updatedUser.id,
|
updatedUser.id,
|
||||||
updatedUser.username,
|
updatedUser.username,
|
||||||
|
@ -271,7 +236,7 @@ private void updateUserAdminEndpoint(ref HttpRequestContext ctx) {
|
||||||
updatedUser.isAdmin
|
updatedUser.isAdmin
|
||||||
));
|
));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
conn.rollback();
|
db.rollback();
|
||||||
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
ctx.response.writeBodyString("Something went wrong: " ~ e.msg);
|
ctx.response.writeBodyString("Something went wrong: " ~ e.msg);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,887 @@
|
||||||
|
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");
|
||||||
|
}
|
|
@ -1,31 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
|
@ -1,77 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
|
@ -1,508 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
|
@ -1,248 +0,0 @@
|
||||||
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.
|
|
||||||
}
|
|
|
@ -1,100 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
module api_modules.classroom_compliance;
|
|
||||||
|
|
|
@ -1,102 +0,0 @@
|
||||||
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,13 +1,19 @@
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
import handy_httpd.handlers.path_handler;
|
import handy_httpd.handlers.path_handler;
|
||||||
|
import d2sqlite3;
|
||||||
import std.process;
|
import std.process;
|
||||||
|
|
||||||
|
import db;
|
||||||
static import api_modules.auth;
|
static import api_modules.auth;
|
||||||
static import api_modules.classroom_compliance.api;
|
static import api_modules.classroom_compliance;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
string env = environment.get("TEACHER_TOOLS_API_ENV", "DEV");
|
string env = environment.get("TEACHER_TOOLS_API_ENV", "DEV");
|
||||||
|
|
||||||
|
// Initialize the database on startup.
|
||||||
|
auto db = getDb();
|
||||||
|
db.close();
|
||||||
|
|
||||||
ServerConfig config;
|
ServerConfig config;
|
||||||
config.enableWebSockets = false;
|
config.enableWebSockets = false;
|
||||||
config.port = 8080;
|
config.port = 8080;
|
||||||
|
@ -23,10 +29,11 @@ void main() {
|
||||||
config.workerPoolSize = 5;
|
config.workerPoolSize = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
PathHandler handler = new PathHandler();
|
PathHandler handler = new PathHandler();
|
||||||
handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint);
|
handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint);
|
||||||
api_modules.auth.registerApiEndpoints(handler);
|
api_modules.auth.registerApiEndpoints(handler);
|
||||||
api_modules.classroom_compliance.api.registerApiEndpoints(handler);
|
api_modules.classroom_compliance.registerApiEndpoints(handler);
|
||||||
|
|
||||||
HttpServer server = new HttpServer(handler, config);
|
HttpServer server = new HttpServer(handler, config);
|
||||||
server.start();
|
server.start();
|
||||||
|
|
135
api/source/db.d
135
api/source/db.d
|
@ -5,111 +5,56 @@ import std.array;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
import std.conv;
|
import std.conv;
|
||||||
|
|
||||||
import ddbc;
|
import d2sqlite3;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
|
|
||||||
private DataSource dataSource;
|
struct Column {
|
||||||
|
const string name;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Connection getDb() {
|
Database getDb() {
|
||||||
return dataSource.getConnection();
|
import std.file;
|
||||||
}
|
bool shouldInitDb = !exists("teacher-tools.db");
|
||||||
|
int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
|
||||||
T[] findAll(T, Args...)(
|
if (d2sqlite3.threadSafe()) {
|
||||||
Connection conn,
|
flags |= SQLITE_OPEN_NOMUTEX;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
return app[];
|
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);
|
||||||
|
|
||||||
Optional!T findOne(T, Args...)(
|
import sample_data;
|
||||||
Connection conn,
|
insertSampleData(db);
|
||||||
string query,
|
|
||||||
T function(DataSetReader) parser,
|
info("Initialized database schema.");
|
||||||
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 Optional!T.empty;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong count(Args...)(Connection conn, string query, Args args) {
|
T[] findAll(T, Args...)(Database db, string query, Args args) {
|
||||||
return findOne(conn, query, r => r.getUlong(1), args).orElse(0);
|
Statement stmt = db.prepare(query);
|
||||||
|
stmt.bindAll(args);
|
||||||
|
ResultRange result = stmt.execute();
|
||||||
|
return result.map!(row => parseRow!T(row)).array;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool recordExists(Args...)(Connection conn, string query, Args args) {
|
Optional!T findOne(T, Args...)(Database db, string query, Args args) {
|
||||||
PreparedStatement ps = conn.prepareStatement(query);
|
Statement stmt = db.prepare(query);
|
||||||
scope(exit) ps.close();
|
stmt.bindAll(args);
|
||||||
bindAllArgs(ps, args);
|
ResultRange result = stmt.execute();
|
||||||
ResultSet rs = ps.executeQuery();
|
if (result.empty) return Optional!T.empty;
|
||||||
scope(exit) rs.close();
|
return Optional!T.of(parseRow!T(result.front));
|
||||||
return rs.next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong insertOne(Args...)(Connection conn, string query, Args args) {
|
bool canFind(Args...)(Database db, string query, Args args) {
|
||||||
PreparedStatement ps = conn.prepareStatement(query);
|
Statement stmt = db.prepare(query);
|
||||||
scope(exit) ps.close();
|
stmt.bindAll(args);
|
||||||
bindAllArgs(ps, args);
|
return !stmt.execute().empty;
|
||||||
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) {
|
private string toSnakeCase(string camelCase) {
|
||||||
|
@ -159,7 +104,7 @@ private string getArgsStr(T)() {
|
||||||
return argsStr;
|
return argsStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// T parseRow(T)(Row row) {
|
T parseRow(T)(Row row) {
|
||||||
// mixin("T t = T(" ~ getArgsStr!T ~ ");");
|
mixin("T t = T(" ~ getArgsStr!T ~ ");");
|
||||||
// return t;
|
return t;
|
||||||
// }
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
module sample_data;
|
module sample_data;
|
||||||
|
|
||||||
import ddbc;
|
|
||||||
import db;
|
import db;
|
||||||
import data_utils;
|
import data_utils;
|
||||||
|
import d2sqlite3;
|
||||||
|
|
||||||
import std.random;
|
import std.random;
|
||||||
import std.algorithm;
|
import std.algorithm;
|
||||||
|
@ -32,27 +32,21 @@ private const STUDENT_NAMES = [
|
||||||
"Cindy"
|
"Cindy"
|
||||||
];
|
];
|
||||||
|
|
||||||
void insertSampleData() {
|
void insertSampleData(ref Database db) {
|
||||||
Connection conn = getDb();
|
db.begin();
|
||||||
conn.setAutoCommit(false);
|
addUser(db, "sample-user-A", "test", false, false);
|
||||||
scope(exit) {
|
addUser(db, "sample-user-B", "test", true, false);
|
||||||
conn.commit();
|
addUser(db, "sample-user-C", "test", false, false);
|
||||||
conn.close();
|
addUser(db, "sample-user-D", "test", false, false);
|
||||||
}
|
|
||||||
|
|
||||||
addUser(conn, "sample-user-A", "test", false, false);
|
ulong adminUserId = addUser(db, "test", "test", false, true);
|
||||||
addUser(conn, "sample-user-B", "test", true, false);
|
ulong normalUserId = addUser(db, "test2", "test", false, 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);
|
Random rand = Random(0);
|
||||||
const SysTime now = Clock.currTime();
|
const SysTime now = Clock.currTime();
|
||||||
const Date today = Date(now.year, now.month, now.day);
|
const Date today = Date(now.year, now.month, now.day);
|
||||||
|
|
||||||
for (ushort i = 1; i <= 6; i++) {
|
for (ushort i = 1; i <= 6; i++) {
|
||||||
ulong classId = addClass(conn, "2024-2025", i, adminUserId);
|
ulong classId = addClass(db, "2024-2025", i, adminUserId);
|
||||||
bool classHasAssignedDesks = uniform01(rand) < 0.5;
|
bool classHasAssignedDesks = uniform01(rand) < 0.5;
|
||||||
size_t count = uniform(10, STUDENT_NAMES.length, rand);
|
size_t count = uniform(10, STUDENT_NAMES.length, rand);
|
||||||
auto studentsToAdd = randomSample(STUDENT_NAMES, count, rand);
|
auto studentsToAdd = randomSample(STUDENT_NAMES, count, rand);
|
||||||
|
@ -63,7 +57,7 @@ void insertSampleData() {
|
||||||
if (classHasAssignedDesks) {
|
if (classHasAssignedDesks) {
|
||||||
assignedDeskNumber = deskNumber++;
|
assignedDeskNumber = deskNumber++;
|
||||||
}
|
}
|
||||||
ulong studentId = addStudent(conn, name, classId, assignedDeskNumber, removed);
|
ulong studentId = addStudent(db, name, classId, assignedDeskNumber, removed);
|
||||||
|
|
||||||
// Add entries for the last N days
|
// Add entries for the last N days
|
||||||
for (int n = 0; n < 30; n++) {
|
for (int n = 0; n < 30; n++) {
|
||||||
|
@ -80,42 +74,36 @@ void insertSampleData() {
|
||||||
behaviorRating = 3;
|
behaviorRating = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addEntry(conn, classId, studentId, entryDate, absent, phoneCompliant, behaviorRating);
|
addEntry(db, classId, studentId, entryDate, absent, phoneCompliant, behaviorRating);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
db.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong addUser(Connection conn, string username, string password, bool locked, bool admin) {
|
ulong addUser(ref Database db, string username, string password, bool locked, bool admin) {
|
||||||
|
const query = "INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES (?, ?, ?, ?, ?)";
|
||||||
import std.digest.sha;
|
import std.digest.sha;
|
||||||
|
import std.stdio;
|
||||||
string passwordHash = cast(string) sha256Of(password).toHexString().idup;
|
string passwordHash = cast(string) sha256Of(password).toHexString().idup;
|
||||||
return insertOne(
|
db.execute(query, username, passwordHash, getUnixTimestampMillis(), locked, admin);
|
||||||
conn,
|
return db.lastInsertRowid();
|
||||||
"INSERT INTO auth_user (username, password_hash, is_locked, is_admin) VALUES (?, ?, ?, ?) RETURNING id",
|
|
||||||
username, passwordHash, locked, admin
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong addClass(Connection conn, string schoolYear, ushort number, ulong userId) {
|
ulong addClass(ref Database db, string schoolYear, ushort number, ulong userId) {
|
||||||
return insertOne(
|
const query = "INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?)";
|
||||||
conn,
|
db.execute(query, number, schoolYear, userId);
|
||||||
"INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?) RETURNING id",
|
return db.lastInsertRowid();
|
||||||
number, schoolYear, userId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong addStudent(Connection conn, string name, ulong classId, ushort deskNumber, bool removed) {
|
ulong addStudent(ref Database db, string name, ulong classId, ushort deskNumber, bool removed) {
|
||||||
return insertOne(
|
const query = "INSERT INTO classroom_compliance_student (name, class_id, desk_number, removed) VALUES (?, ?, ?, ?)";
|
||||||
conn,
|
db.execute(query, name, classId, deskNumber, removed);
|
||||||
"INSERT INTO classroom_compliance_student
|
return db.lastInsertRowid();
|
||||||
(name, class_id, desk_number, removed)
|
|
||||||
VALUES (?, ?, ?, ?) RETURNING id",
|
|
||||||
name, classId, deskNumber, removed
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void addEntry(
|
void addEntry(
|
||||||
Connection conn,
|
ref Database db,
|
||||||
ulong classId,
|
ulong classId,
|
||||||
ulong studentId,
|
ulong studentId,
|
||||||
Date date,
|
Date date,
|
||||||
|
@ -125,21 +113,24 @@ void addEntry(
|
||||||
) {
|
) {
|
||||||
const entryQuery = "
|
const entryQuery = "
|
||||||
INSERT INTO classroom_compliance_entry
|
INSERT INTO classroom_compliance_entry
|
||||||
(class_id, student_id, date, absent, comment, phone_compliant, behavior_rating)
|
(class_id, student_id, date, created_at, absent, comment)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
PreparedStatement ps = conn.prepareStatement(entryQuery);
|
db.execute(
|
||||||
scope(exit) ps.close();
|
entryQuery,
|
||||||
ps.setUlong(1, classId);
|
classId,
|
||||||
ps.setUlong(2, studentId);
|
studentId,
|
||||||
ps.setDate(3, date);
|
date.toISOExtString(),
|
||||||
ps.setBoolean(4, absent);
|
getUnixTimestampMillis(),
|
||||||
ps.setString(5, "Testing comment");
|
absent,
|
||||||
if (absent) {
|
"Sample comment."
|
||||||
ps.setNull(6);
|
);
|
||||||
ps.setNull(7);
|
if (absent) return;
|
||||||
} else {
|
ulong entryId = db.lastInsertRowid();
|
||||||
ps.setBoolean(6, phoneCompliant);
|
const phoneQuery = "INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)";
|
||||||
ps.setUint(7, behaviorRating);
|
db.execute(phoneQuery, entryId, phoneCompliant);
|
||||||
}
|
const behaviorQuery = "
|
||||||
ps.executeUpdate();
|
INSERT INTO classroom_compliance_entry_behavior
|
||||||
|
(entry_id, rating)
|
||||||
|
VALUES (?, ?)";
|
||||||
|
db.execute(behaviorQuery, entryId, behaviorRating);
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,13 +24,21 @@ export interface Student {
|
||||||
removed: boolean
|
removed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryPhone {
|
||||||
|
compliant: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryBehavior {
|
||||||
|
rating: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Entry {
|
export interface Entry {
|
||||||
id: number
|
id: number
|
||||||
date: string
|
date: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
absent: boolean
|
absent: boolean
|
||||||
phoneCompliant: boolean | null
|
phone: EntryPhone | null
|
||||||
behaviorRating: number | null
|
behavior: EntryBehavior | null
|
||||||
comment: string
|
comment: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,8 +48,8 @@ export function getDefaultEntry(dateStr: string): Entry {
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
absent: false,
|
absent: false,
|
||||||
phoneCompliant: true,
|
phone: { compliant: true },
|
||||||
behaviorRating: 3,
|
behavior: { rating: 3 },
|
||||||
comment: '',
|
comment: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,15 +15,13 @@ const cls: Ref<Class | null> = ref(null)
|
||||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const idNumber = parseInt(props.id, 10)
|
const idNumber = parseInt(props.id, 10)
|
||||||
apiClient.getClass(idNumber).handleErrorsWithAlert().then(result => {
|
cls.value = await apiClient.getClass(idNumber).handleErrorsWithAlert()
|
||||||
if (result) {
|
if (!cls.value) {
|
||||||
cls.value = result
|
await router.back()
|
||||||
} else {
|
return
|
||||||
router.back();
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function deleteThisClass() {
|
async function deleteThisClass() {
|
||||||
|
|
|
@ -12,9 +12,7 @@ const router = useRouter()
|
||||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
apiClient.getClasses().handleErrorsWithAlert().then(result => {
|
classes.value = (await apiClient.getClasses().handleErrorsWithAlert()) ?? []
|
||||||
if (result) classes.value = result
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -38,21 +38,21 @@ function toggleAbsence() {
|
||||||
model.value.absent = !model.value.absent
|
model.value.absent = !model.value.absent
|
||||||
if (model.value.absent) {
|
if (model.value.absent) {
|
||||||
// Remove additional data if student is absent.
|
// Remove additional data if student is absent.
|
||||||
model.value.phoneCompliant = null
|
model.value.phone = null
|
||||||
model.value.behaviorRating = null
|
model.value.behavior = null
|
||||||
} else {
|
} else {
|
||||||
// Populate default additional data if student is no longer absent.
|
// Populate default additional data if student is no longer absent.
|
||||||
model.value.phoneCompliant = true
|
model.value.phone = { compliant: true }
|
||||||
model.value.behaviorRating = 3
|
model.value.behavior = { rating: 3 }
|
||||||
// If we have an initial entry known, restore data from that.
|
// If we have an initial entry known, restore data from that.
|
||||||
if (initialEntryJson.value) {
|
if (initialEntryJson.value) {
|
||||||
const initialEntry = JSON.parse(initialEntryJson.value) as Entry
|
const initialEntry = JSON.parse(initialEntryJson.value) as Entry
|
||||||
if (initialEntry.absent) return
|
if (initialEntry.absent) return
|
||||||
if (initialEntry.phoneCompliant) {
|
if (initialEntry.phone) {
|
||||||
model.value.phoneCompliant = initialEntry.phoneCompliant
|
model.value.phone = { compliant: initialEntry.phone?.compliant }
|
||||||
}
|
}
|
||||||
if (initialEntry.behaviorRating) {
|
if (initialEntry.behavior) {
|
||||||
model.value.behaviorRating = initialEntry.behaviorRating
|
model.value.behavior = { rating: initialEntry.behavior.rating }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -60,16 +60,16 @@ function toggleAbsence() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePhoneCompliance() {
|
function togglePhoneCompliance() {
|
||||||
if (model.value && model.value.phoneCompliant !== null) {
|
if (model.value && model.value.phone) {
|
||||||
model.value.phoneCompliant = !model.value.phoneCompliant
|
model.value.phone.compliant = !model.value.phone.compliant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleBehaviorRating() {
|
function toggleBehaviorRating() {
|
||||||
if (model.value && model.value.behaviorRating) {
|
if (model.value && model.value.behavior) {
|
||||||
model.value.behaviorRating = model.value.behaviorRating - 1
|
model.value.behavior.rating = model.value.behavior.rating - 1
|
||||||
if (model.value.behaviorRating < 1) {
|
if (model.value.behavior.rating < 1) {
|
||||||
model.value.behaviorRating = 3
|
model.value.behavior.rating = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,13 +111,13 @@ function addEntry() {
|
||||||
<span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span>
|
<span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
|
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
|
||||||
<span v-if="model.phoneCompliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span>
|
<span v-if="model.phone?.compliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span>
|
||||||
<span v-if="!model.phoneCompliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
|
<span v-if="!model.phone?.compliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent">
|
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent">
|
||||||
<span v-if="model.behaviorRating === 3" title="Good Behavior">{{ EMOJI_BEHAVIOR_GOOD }}</span>
|
<span v-if="model.behavior?.rating === 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.behavior?.rating === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
|
||||||
<span v-if="model.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
<span v-if="model.behavior?.rating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item" @click="showCommentEditor">
|
<div class="status-item" @click="showCommentEditor">
|
||||||
<span v-if="hasComment"
|
<span v-if="hasComment"
|
||||||
|
|
|
@ -12,12 +12,12 @@ defineProps<{
|
||||||
<span v-if="entry.absent">{{ EMOJI_ABSENT }}</span>
|
<span v-if="entry.absent">{{ EMOJI_ABSENT }}</span>
|
||||||
<span v-if="!entry.absent">{{ EMOJI_PRESENT }}</span>
|
<span v-if="!entry.absent">{{ EMOJI_PRESENT }}</span>
|
||||||
|
|
||||||
<span v-if="entry.phoneCompliant === true">{{ EMOJI_PHONE_COMPLIANT }}</span>
|
<span v-if="entry.phone && entry.phone.compliant">{{ EMOJI_PHONE_COMPLIANT }}</span>
|
||||||
<span v-if="entry.phoneCompliant === false">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
|
<span v-if="entry.phone && !entry.phone.compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
|
||||||
|
|
||||||
<span v-if="entry.behaviorRating === 3">{{ EMOJI_BEHAVIOR_GOOD }}</span>
|
<span v-if="entry.behavior && entry.behavior.rating === 3">{{ EMOJI_BEHAVIOR_GOOD }}</span>
|
||||||
<span v-if="entry.behaviorRating === 2">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
|
<span v-if="entry.behavior && entry.behavior.rating === 2">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
|
||||||
<span v-if="entry.behaviorRating === 1">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
<span v-if="entry.behavior && entry.behavior.rating === 1">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="entry.comment.trim().length > 0" class="comment">
|
<p v-if="entry.comment.trim().length > 0" class="comment">
|
||||||
{{ entry.comment }}
|
{{ entry.comment }}
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=teacher-tools-api
|
Description=teacher-tools-api
|
||||||
After=network.target postgresql.service
|
After=network.target
|
||||||
Wants=postgresql.service
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=root
|
User=root
|
||||||
WorkingDirectory=/opt/teacher-tools
|
WorkingDirectory=/opt/teacher-tools
|
||||||
EnvironmentFile=/opt/teacher-tools/prod.env
|
Environment="TEACHER_TOOLS_API_ENV=PROD"
|
||||||
ExecStart=/opt/teacher-tools/teacher-tools-api
|
ExecStart=/opt/teacher-tools/teacher-tools-api
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue