Added bruno API spec, and implemented most of the backend API.
This commit is contained in:
parent
277af441e1
commit
a52e13fe57
|
@ -6,11 +6,3 @@ CREATE TABLE user (
|
||||||
is_locked INTEGER NOT NULL,
|
is_locked INTEGER NOT NULL,
|
||||||
is_admin INTEGER NOT NULL
|
is_admin INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES (
|
|
||||||
'test',
|
|
||||||
'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08',
|
|
||||||
1734380300,
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
);
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
CREATE TABLE classroom_compliance_class (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
number INTEGER NOT NULL,
|
||||||
|
school_year TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES user(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE classroom_compliance_student (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE classroom_compliance_desk_assignment (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
desk_number INTEGER NOT NULL,
|
||||||
|
student_id INTEGER REFERENCES classroom_compliance_student(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE classroom_compliance_entry (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
class_description TEXT NOT NULL,
|
||||||
|
student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id),
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
absent INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE classroom_compliance_entry_phone (
|
||||||
|
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
compliant INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE classroom_compliance_entry_behavior (
|
||||||
|
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
rating INTEGER NOT NULL,
|
||||||
|
comment TEXT
|
||||||
|
);
|
|
@ -0,0 +1,17 @@
|
||||||
|
-- TEST DATA
|
||||||
|
|
||||||
|
-- username: test, password: test
|
||||||
|
INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES (
|
||||||
|
'test',
|
||||||
|
'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08',
|
||||||
|
1734380300,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
INSERT INTO user (username, password_hash, created_at, is_locked, is_admin) VALUES (
|
||||||
|
'test2',
|
||||||
|
'9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08',
|
||||||
|
1734394062,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
|
@ -11,17 +11,13 @@ import data_utils;
|
||||||
struct User {
|
struct User {
|
||||||
const ulong id;
|
const ulong id;
|
||||||
const string username;
|
const string username;
|
||||||
@Column("password_hash")
|
|
||||||
const string passwordHash;
|
const string passwordHash;
|
||||||
@Column("created_at")
|
|
||||||
const ulong createdAt;
|
const ulong createdAt;
|
||||||
@Column("is_locked")
|
|
||||||
const bool isLocked;
|
const bool isLocked;
|
||||||
@Column("is_admin")
|
|
||||||
const bool isAdmin;
|
const bool isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UserResponse {
|
private struct UserResponse {
|
||||||
ulong id;
|
ulong id;
|
||||||
string username;
|
string username;
|
||||||
ulong createdAt;
|
ulong createdAt;
|
||||||
|
|
|
@ -0,0 +1,373 @@
|
||||||
|
module api_modules.classroom_compliance;
|
||||||
|
|
||||||
|
import handy_httpd;
|
||||||
|
import handy_httpd.handlers.path_handler;
|
||||||
|
import std.typecons : Nullable;
|
||||||
|
import d2sqlite3;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClassroomComplianceDeskAssignment {
|
||||||
|
const ulong id;
|
||||||
|
const ulong classId;
|
||||||
|
const ushort deskNumber;
|
||||||
|
const ulong studentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClassroomComplianceEntry {
|
||||||
|
const ulong id;
|
||||||
|
const ulong classId;
|
||||||
|
const string classDescription;
|
||||||
|
const ulong studentId;
|
||||||
|
const string date;
|
||||||
|
const ulong createdAt;
|
||||||
|
const bool absent;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClassroomComplianceEntryPhone {
|
||||||
|
const ulong entryId;
|
||||||
|
const bool compliant;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClassroomComplianceEntryBehavior {
|
||||||
|
const ulong entryId;
|
||||||
|
const ubyte rating;
|
||||||
|
const string comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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.PUT, STUDENT_PATH, &updateStudent);
|
||||||
|
handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent);
|
||||||
|
|
||||||
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/desk-assignments", &setDeskAssignments);
|
||||||
|
handler.addMapping(Method.GET, CLASS_PATH ~ "/desk-assignments", &getDeskAssignments);
|
||||||
|
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/desk-assignments", &removeAllDeskAssignments);
|
||||||
|
|
||||||
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &createEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
void createClass(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
struct ClassPayload {
|
||||||
|
ushort number;
|
||||||
|
string schoolYear;
|
||||||
|
}
|
||||||
|
auto payload = readJsonPayload!(ClassPayload)(ctx);
|
||||||
|
auto db = getDb();
|
||||||
|
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 this 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) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto db = getDb();
|
||||||
|
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 deleteClass(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
auto db = getDb();
|
||||||
|
db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, in User user) {
|
||||||
|
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
||||||
|
auto db = getDb();
|
||||||
|
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) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
struct StudentPayload {
|
||||||
|
string name;
|
||||||
|
}
|
||||||
|
auto payload = readJsonPayload!(StudentPayload)(ctx);
|
||||||
|
auto db = getDb();
|
||||||
|
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("Student with that name already exists.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.execute("INSERT INTO classroom_compliance_student (name, class_id) VALUES (?, ?)", payload.name, cls.id);
|
||||||
|
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) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
auto db = getDb();
|
||||||
|
auto students = findAll!(ClassroomComplianceStudent)(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
||||||
|
cls.id
|
||||||
|
);
|
||||||
|
writeJsonBody(ctx, students);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateStudent(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto student = getStudentOrThrow(ctx, user);
|
||||||
|
struct StudentUpdatePayload {
|
||||||
|
string name;
|
||||||
|
}
|
||||||
|
auto payload = readJsonPayload!(StudentUpdatePayload)(ctx);
|
||||||
|
// If there is nothing to update, quit.
|
||||||
|
if (payload.name == student.name) return;
|
||||||
|
// Check that the new name doesn't already exist.
|
||||||
|
auto db = getDb();
|
||||||
|
bool newNameExists = 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("Student with that name already exists.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
db.execute(
|
||||||
|
"UPDATE classroom_compliance_student SET name = ? WHERE id = ?",
|
||||||
|
payload.name,
|
||||||
|
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) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto student = getStudentOrThrow(ctx, user);
|
||||||
|
auto db = getDb();
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?",
|
||||||
|
student.id,
|
||||||
|
student.classId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User user) {
|
||||||
|
ulong classId = ctx.request.getPathParamAs!ulong("classId");
|
||||||
|
ulong studentId = ctx.request.getPathParamAs!ulong("studentId");
|
||||||
|
auto db = getDb();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DeskAssignmentPayloadEntry {
|
||||||
|
ushort deskNumber;
|
||||||
|
Nullable!ulong studentId;
|
||||||
|
}
|
||||||
|
private struct DeskAssignmentPayload {
|
||||||
|
DeskAssignmentPayloadEntry[] entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDeskAssignments(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
auto payload = readJsonPayload!(DeskAssignmentPayload)(ctx);
|
||||||
|
auto db = getDb();
|
||||||
|
auto validationError = validateDeskAssignments(db, payload, cls.id);
|
||||||
|
if (validationError) {
|
||||||
|
import slf4d;
|
||||||
|
warnF!"Desk assignment validation failed: %s"(validationError.value);
|
||||||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
|
ctx.response.writeBodyString(validationError.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.begin();
|
||||||
|
try {
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM classroom_compliance_desk_assignment WHERE class_id = ?",
|
||||||
|
cls.id
|
||||||
|
);
|
||||||
|
auto stmt = db.prepare(
|
||||||
|
"INSERT INTO classroom_compliance_desk_assignment (class_id, desk_number, student_id) VALUES (?, ?, ?)"
|
||||||
|
);
|
||||||
|
foreach (entry; payload.entries) {
|
||||||
|
stmt.bindAll(cls.id, entry.deskNumber, entry.studentId);
|
||||||
|
stmt.execute();
|
||||||
|
stmt.clearBindings();
|
||||||
|
stmt.reset();
|
||||||
|
}
|
||||||
|
db.commit();
|
||||||
|
// Return the new list of desk assignments to the user.
|
||||||
|
auto newAssignments = findAll!(ClassroomComplianceDeskAssignment)(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_desk_assignment WHERE class_id = ?",
|
||||||
|
cls.id
|
||||||
|
);
|
||||||
|
writeJsonBody(ctx, newAssignments);
|
||||||
|
} catch (Exception e) {
|
||||||
|
db.rollback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!string validateDeskAssignments(Database db, in DeskAssignmentPayload payload, ulong classId) {
|
||||||
|
import std.algorithm : canFind, map;
|
||||||
|
import std.array;
|
||||||
|
// Check that desks are numbered 1 .. N.
|
||||||
|
for (int n = 1; n <= payload.entries.length; n++) {
|
||||||
|
bool deskPresent = false;
|
||||||
|
foreach (entry; payload.entries) {
|
||||||
|
if (entry.deskNumber == n) {
|
||||||
|
deskPresent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!deskPresent) return Optional!string.of("Desks should be numbered from 1 to N.");
|
||||||
|
}
|
||||||
|
auto allStudents = findAll!(ClassroomComplianceStudent)(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_student WHERE class_id = ?",
|
||||||
|
classId
|
||||||
|
);
|
||||||
|
auto studentIds = allStudents.map!(s => s.id).array;
|
||||||
|
// Check that if a desk is assigned to a student, that it's one from this class.
|
||||||
|
foreach (entry; payload.entries) {
|
||||||
|
if (!entry.studentId.isNull && !canFind(studentIds, entry.studentId.get)) {
|
||||||
|
return Optional!string.of("Desks cannot be assigned to students that don't belong to this class.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that each student in the class is assigned a desk.
|
||||||
|
ushort[] takenDesks;
|
||||||
|
foreach (student; allStudents) {
|
||||||
|
ushort[] assignedDesks;
|
||||||
|
foreach (entry; payload.entries) {
|
||||||
|
if (!entry.studentId.isNull && entry.studentId.get() == student.id) {
|
||||||
|
assignedDesks ~= entry.deskNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (assignedDesks.length != 1) {
|
||||||
|
if (assignedDesks.length > 1) {
|
||||||
|
return Optional!string.of("A student is assigned to more than one desk.");
|
||||||
|
} else {
|
||||||
|
return Optional!string.of("Not all students are assigned to a desk.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canFind(takenDesks, assignedDesks[0])) {
|
||||||
|
return Optional!string.of("Cannot assign more than one student to the same desk.");
|
||||||
|
}
|
||||||
|
takenDesks ~= assignedDesks[0];
|
||||||
|
}
|
||||||
|
return Optional!string.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void getDeskAssignments(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
auto db = getDb();
|
||||||
|
auto deskAssignments = findAll!(ClassroomComplianceDeskAssignment)(
|
||||||
|
db,
|
||||||
|
"
|
||||||
|
SELECT d.* FROM classroom_compliance_desk_assignment d
|
||||||
|
WHERE class_id = ?
|
||||||
|
ORDER BY desk_number ASC
|
||||||
|
",
|
||||||
|
cls.id
|
||||||
|
);
|
||||||
|
writeJsonBody(ctx, deskAssignments);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAllDeskAssignments(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
auto db = getDb();
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM classroom_compliance_desk_assignment WHERE class_id = ?",
|
||||||
|
cls.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void createEntry(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
|
||||||
|
}
|
|
@ -5,8 +5,13 @@ import d2sqlite3;
|
||||||
|
|
||||||
import db;
|
import db;
|
||||||
import api_modules.auth;
|
import api_modules.auth;
|
||||||
|
static import api_modules.classroom_compliance;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
// 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;
|
||||||
|
@ -21,6 +26,7 @@ void main() {
|
||||||
PathHandler handler = new PathHandler();
|
PathHandler handler = new PathHandler();
|
||||||
handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint);
|
handler.addMapping(Method.OPTIONS, "/api/**", &optionsEndpoint);
|
||||||
handler.addMapping(Method.POST, "/api/auth/login", &loginEndpoint);
|
handler.addMapping(Method.POST, "/api/auth/login", &loginEndpoint);
|
||||||
|
api_modules.classroom_compliance.registerApiEndpoints(handler);
|
||||||
|
|
||||||
HttpServer server = new HttpServer(handler, config);
|
HttpServer server = new HttpServer(handler, config);
|
||||||
server.start();
|
server.start();
|
||||||
|
|
|
@ -19,6 +19,8 @@ T readJsonPayload(T)(ref HttpRequestContext ctx) {
|
||||||
string requestBody = ctx.request.readBodyAsString();
|
string requestBody = ctx.request.readBodyAsString();
|
||||||
return deserialize!T(requestBody);
|
return deserialize!T(requestBody);
|
||||||
} catch (SerdeException e) {
|
} catch (SerdeException e) {
|
||||||
|
import slf4d;
|
||||||
|
warnF!"Failed to read JSON payload: %s"(e.msg);
|
||||||
throw new HttpStatusException(HttpStatus.BAD_REQUEST);
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,10 +33,25 @@ T readJsonPayload(T)(ref HttpRequestContext ctx) {
|
||||||
* data = The data to write.
|
* data = The data to write.
|
||||||
*/
|
*/
|
||||||
void writeJsonBody(T)(ref HttpRequestContext ctx, in T data) {
|
void writeJsonBody(T)(ref HttpRequestContext ctx, in T data) {
|
||||||
|
import std.traits : isArray;
|
||||||
try {
|
try {
|
||||||
|
static if (isArray!T) {
|
||||||
|
if (data.length == 0) {
|
||||||
|
ctx.response.writeBodyString("[]", "application/json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
string jsonStr = serializeToJson(data);
|
string jsonStr = serializeToJson(data);
|
||||||
ctx.response.writeBodyString(jsonStr, "application/json");
|
ctx.response.writeBodyString(jsonStr, "application/json");
|
||||||
} catch (SerdeException e) {
|
} catch (SerdeException e) {
|
||||||
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ulong getUnixTimestampMillis() {
|
||||||
|
import std.datetime;
|
||||||
|
SysTime now = Clock.currTime();
|
||||||
|
SysTime unixEpoch = SysTime(DateTime(1970, 1, 1), UTC());
|
||||||
|
Duration diff = now - unixEpoch;
|
||||||
|
return diff.total!"msecs";
|
||||||
|
}
|
||||||
|
|
|
@ -22,14 +22,61 @@ Database getDb() {
|
||||||
}
|
}
|
||||||
Database db = Database("teacher-tools.db", flags);
|
Database db = Database("teacher-tools.db", flags);
|
||||||
db.execute("PRAGMA foreign_keys=ON");
|
db.execute("PRAGMA foreign_keys=ON");
|
||||||
const string schema = import("schema.sql");
|
|
||||||
if (shouldInitDb) {
|
if (shouldInitDb) {
|
||||||
db.run(schema);
|
const string authSchema = import("schema/auth.sql");
|
||||||
|
const string classroomComplianceSchema = import("schema/classroom_compliance.sql");
|
||||||
|
db.run(authSchema);
|
||||||
|
db.run(classroomComplianceSchema);
|
||||||
|
|
||||||
|
const string sampleData = import("schema/sample_data.sql");
|
||||||
|
db.run(sampleData);
|
||||||
|
|
||||||
info("Initialized database schema.");
|
info("Initialized database schema.");
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
T[] findAll(T, Args...)(Database db, string query, Args args) {
|
||||||
|
Statement stmt = db.prepare(query);
|
||||||
|
stmt.bindAll(args);
|
||||||
|
ResultRange result = stmt.execute();
|
||||||
|
return result.map!(row => parseRow!T(row)).array;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!T findOne(T, Args...)(Database db, string query, Args args) {
|
||||||
|
Statement stmt = db.prepare(query);
|
||||||
|
stmt.bindAll(args);
|
||||||
|
ResultRange result = stmt.execute();
|
||||||
|
if (result.empty) return Optional!T.empty;
|
||||||
|
return Optional!T.of(parseRow!T(result.front));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canFind(Args...)(Database db, string query, Args args) {
|
||||||
|
Statement stmt = db.prepare(query);
|
||||||
|
stmt.bindAll(args);
|
||||||
|
return !stmt.execute().empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string toSnakeCase(string camelCase) {
|
||||||
|
import std.uni;
|
||||||
|
if (camelCase.length == 0) return camelCase;
|
||||||
|
auto app = appender!string;
|
||||||
|
app ~= toLower(camelCase[0]);
|
||||||
|
for (int i = 1; i < camelCase.length; i++) {
|
||||||
|
if (isUpper(camelCase[i])) {
|
||||||
|
app ~= '_';
|
||||||
|
app ~= toLower(camelCase[i]);
|
||||||
|
} else {
|
||||||
|
app ~= camelCase[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app[];
|
||||||
|
}
|
||||||
|
|
||||||
|
unittest {
|
||||||
|
assert(toSnakeCase("testValue") == "test_value");
|
||||||
|
}
|
||||||
|
|
||||||
private string[] getColumnNames(T)() {
|
private string[] getColumnNames(T)() {
|
||||||
import std.string : toLower;
|
import std.string : toLower;
|
||||||
alias members = __traits(allMembers, T);
|
alias members = __traits(allMembers, T);
|
||||||
|
@ -38,7 +85,7 @@ private string[] getColumnNames(T)() {
|
||||||
static if (__traits(getAttributes, __traits(getMember, T, members[i])).length > 0) {
|
static if (__traits(getAttributes, __traits(getMember, T, members[i])).length > 0) {
|
||||||
columnNames[i] = toLower(__traits(getAttributes, __traits(getMember, T, members[i]))[0].name);
|
columnNames[i] = toLower(__traits(getAttributes, __traits(getMember, T, members[i]))[0].name);
|
||||||
} else {
|
} else {
|
||||||
columnNames[i] = toLower(members[i]);
|
columnNames[i] = toLower(toSnakeCase(members[i]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return columnNames.dup;
|
return columnNames.dup;
|
||||||
|
@ -61,18 +108,3 @@ T parseRow(T)(Row row) {
|
||||||
mixin("T t = T(" ~ getArgsStr!T ~ ");");
|
mixin("T t = T(" ~ getArgsStr!T ~ ");");
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
T[] findAll(T, Args...)(Database db, string query, Args args) {
|
|
||||||
Statement stmt = db.prepare(query);
|
|
||||||
stmt.bindAll(args);
|
|
||||||
ResultRange result = stmt.execute();
|
|
||||||
return result.map!(row => parseRow!T(row)).array;
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional!T findOne(T, Args...)(Database db, string query, Args args) {
|
|
||||||
Statement stmt = db.prepare(query);
|
|
||||||
stmt.bindAll(args);
|
|
||||||
ResultRange result = stmt.execute();
|
|
||||||
if (result.empty) return Optional!T.empty;
|
|
||||||
return Optional!T.of(parseRow!T(result.front));
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
meta {
|
||||||
|
name: Login
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/auth/login
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
meta {
|
||||||
|
name: Create Class
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"number": 2,
|
||||||
|
"schoolYear": "2024-2025"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
meta {
|
||||||
|
name: Create Compliance Entry
|
||||||
|
type: http
|
||||||
|
seq: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId/entries
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: {{class_id}}
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"student_id": 1,
|
||||||
|
"date": "2024-12-16",
|
||||||
|
"absent": false,
|
||||||
|
"phone": {
|
||||||
|
"compliant": true
|
||||||
|
},
|
||||||
|
"behavior": {
|
||||||
|
"rating": 3,
|
||||||
|
"comment": "Good job!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
meta {
|
||||||
|
name: Create Student
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId/students
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: {{class_id}}
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"name": "John F. Kennedy"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
meta {
|
||||||
|
name: Delete Class
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: 1
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
meta {
|
||||||
|
name: Delete Student
|
||||||
|
type: http
|
||||||
|
seq: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId/students/:studentId
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: {{class_id}}
|
||||||
|
studentId: 3
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
meta {
|
||||||
|
name: Get Classes
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
meta {
|
||||||
|
name: Get Desk Assignments
|
||||||
|
type: http
|
||||||
|
seq: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId/desk-assignments
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: {{class_id}}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
meta {
|
||||||
|
name: Get Students
|
||||||
|
type: http
|
||||||
|
seq: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId/students
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: {{class_id}}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
meta {
|
||||||
|
name: Set Desk Assignments
|
||||||
|
type: http
|
||||||
|
seq: 9
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId/desk-assignments
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: {{class_id}}
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"deskNumber": 1,
|
||||||
|
"studentId": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deskNumber": 2,
|
||||||
|
"studentId": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
meta {
|
||||||
|
name: Update Student
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
put {
|
||||||
|
url: {{base_url}}/classroom-compliance/classes/:classId/students/:studentId
|
||||||
|
body: json
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
params:path {
|
||||||
|
classId: {{class_id}}
|
||||||
|
studentId: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"name": "John W. Booth"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
meta {
|
||||||
|
name: Classroom Compliance
|
||||||
|
}
|
||||||
|
|
||||||
|
vars:pre-request {
|
||||||
|
class_id: 1
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "Teacher-Tools",
|
||||||
|
"type": "collection",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
auth {
|
||||||
|
mode: basic
|
||||||
|
}
|
||||||
|
|
||||||
|
auth:basic {
|
||||||
|
username: test
|
||||||
|
password: test
|
||||||
|
}
|
||||||
|
|
||||||
|
vars:pre-request {
|
||||||
|
base_url: http://localhost:8080/api
|
||||||
|
}
|
Loading…
Reference in New Issue