diff --git a/api/source/api_modules/classroom_compliance/api.d b/api/source/api_modules/classroom_compliance/api.d
index 32f976d..5cc692f 100644
--- a/api/source/api_modules/classroom_compliance/api.d
+++ b/api/source/api_modules/classroom_compliance/api.d
@@ -6,6 +6,7 @@ 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;
+import api_modules.classroom_compliance.api_export;
void registerApiEndpoints(PathHandler handler) {
const ROOT_PATH = "/api/classroom-compliance";
@@ -31,4 +32,6 @@ void registerApiEndpoints(PathHandler handler) {
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries);
+
+ handler.addMapping(Method.GET, ROOT_PATH ~ "/export", &getFullExport);
}
\ No newline at end of file
diff --git a/api/source/api_modules/classroom_compliance/api_export.d b/api/source/api_modules/classroom_compliance/api_export.d
new file mode 100644
index 0000000..116c25b
--- /dev/null
+++ b/api/source/api_modules/classroom_compliance/api_export.d
@@ -0,0 +1,153 @@
+module api_modules.classroom_compliance.api_export;
+
+import handy_httpd;
+import ddbc;
+import streams;
+import std.datetime;
+import std.conv;
+import slf4d;
+
+import api_modules.auth : User, getUserOrThrow;
+import db;
+import data_utils;
+
+void getFullExport(ref HttpRequestContext ctx) {
+ Connection conn = getDb();
+ scope(exit) conn.close();
+ User user = getUserOrThrow(ctx, conn);
+
+ const query = "
+ SELECT
+ c.id AS class_id,
+ c.number AS class_number,
+ c.school_year AS school_year,
+ s.id AS student_id,
+ s.name AS student_name,
+ s.desk_number AS desk_number,
+ s.removed AS student_removed,
+ e.date AS entry_date,
+ e.created_at AS entry_created_at,
+ e.absent AS absent,
+ e.phone_compliant AS phone_compliant,
+ e.behavior_rating AS behavior_rating,
+ e.comment AS comment
+ FROM classroom_compliance_class c
+ LEFT JOIN classroom_compliance_student s
+ ON s.class_id = c.id
+ LEFT JOIN classroom_compliance_entry e
+ ON e.class_id = c.id AND e.student_id = s.id
+ WHERE c.user_id = ?
+ ";
+
+ ctx.response.addHeader("Content-Type", "text/csv");
+ ctx.response.addHeader("Content-Disposition", "attachment; filename=export.csv");
+ ctx.response.flushHeaders();
+
+ PreparedStatement stmt = conn.prepareStatement(query);
+ scope(exit) stmt.close();
+ stmt.setUlong(1, user.id);
+ ResultSet rs = stmt.executeQuery();
+ scope(exit) rs.close();
+ auto s = csvOutputStreamFor(ctx.response.outputStream);
+
+ const CSVColumnDef[] columns = [
+ CSVColumnDef("Class ID", (r, i) => r.getUlong(i).to!string),
+ CSVColumnDef("Class Number", (r, i) => r.getUint(i).to!string),
+ CSVColumnDef("School Year", (r, i) => r.getString(i)),
+ CSVColumnDef("Student ID", (r, i) => r.getUlong(i).to!string),
+ CSVColumnDef("Student Name", (r, i) => r.getString(i)),
+ CSVColumnDef("Desk Number", (r, i) => r.getUint(i).to!string),
+ CSVColumnDef("Student Removed", (r, i) => r.getBoolean(i).to!string),
+ CSVColumnDef("Entry Date", (r, i) => r.getDate(i).toISOExtString()),
+ CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))),
+ CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"),
+ CSVColumnDef("Phone Compliant", &formatPhoneCompliance),
+ CSVColumnDef("Behavior Rating", &formatBehaviorRating),
+ CSVColumnDef("Comment", &formatComment)
+ ];
+ // Write headers first.
+ for (size_t i = 0; i < columns.length; i++) {
+ s.writeCell(columns[i].header);
+ if (i + 1 < columns.length) s.writeComma();
+ }
+ s.writeLine();
+
+ foreach (DataSetReader r; rs) {
+ for (size_t i = 0; i < columns.length; i++) {
+ int rsIdx = cast(int) i + 1;
+ s.writeCell(columns[i].mapper(r, rsIdx));
+ if (i + 1 < columns.length) s.writeComma();
+ }
+ s.writeLine();
+ }
+}
+
+private string formatCreationTimestamp(ulong unixMillis) {
+ ulong seconds = unixMillis / 1000;
+ ulong millis = unixMillis % 1000;
+ SysTime t = SysTime.fromUnixTime(seconds);
+ t = SysTime(DateTime(t.year, t.month, t.day, t.hour, t.minute, t.second), msecs(millis));
+ return t.toISOExtString();
+}
+
+private string formatPhoneCompliance(DataSetReader r, int i) {
+ if (r.isNull(i)) return "N/A";
+ return r.getBoolean(i) ? "Compliant" : "Non-compliant";
+}
+
+private string formatBehaviorRating(DataSetReader r, int i) {
+ if (r.isNull(i)) return "N/A";
+ ubyte score = r.getUbyte(i);
+ if (score == 3) return "Good";
+ if (score == 2) return "Mediocre";
+ return "Poor";
+}
+
+private string formatComment(DataSetReader r, int i) {
+ if (r.isNull(i)) return "";
+ string c = r.getString(i);
+ if (c.length > 0) return c;
+ return "";
+}
+
+private struct CSVColumnDef {
+ const string header;
+ string function(DataSetReader, int) mapper;
+}
+
+private struct CSVOutputStream(S) if (isByteOutputStream!S) {
+ private S stream;
+
+ this(S stream) {
+ this.stream = stream;
+ }
+
+ StreamResult writeToStream(ubyte[] buffer) {
+ return this.stream.writeToStream(buffer);
+ }
+
+ void writeElement(string s) {
+ StreamResult r = writeToStream(cast(ubyte[]) s);
+ if (r.hasError) {
+ throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, cast(string) r.error.message);
+ } else if (r.count != s.length) {
+ throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Couldn't write all data to stream.");
+ }
+ }
+
+ void writeCell(string s) {
+ writeElement(s);
+ }
+
+ void writeComma() {
+ writeElement(",");
+ }
+
+ void writeLine() {
+ writeElement("\r\n");
+ }
+}
+
+private CSVOutputStream!(S) csvOutputStreamFor(S)(S stream) if (isByteOutputStream!S) {
+ return CSVOutputStream!(S)(stream);
+}
diff --git a/app/src/api/classroom_compliance.ts b/app/src/api/classroom_compliance.ts
index a1bae7a..cd14cc3 100644
--- a/app/src/api/classroom_compliance.ts
+++ b/app/src/api/classroom_compliance.ts
@@ -1,6 +1,6 @@
import { APIClient, type APIResponse, type AuthStoreType } from './base'
-const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
+export const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
export const EMOJI_PHONE_COMPLIANT = '📱'
export const EMOJI_PHONE_NONCOMPLIANT = '📵'
diff --git a/app/src/apps/classroom_compliance/MainView.vue b/app/src/apps/classroom_compliance/MainView.vue
index 1aa3f05..084409a 100644
--- a/app/src/apps/classroom_compliance/MainView.vue
+++ b/app/src/apps/classroom_compliance/MainView.vue
@@ -1,6 +1,24 @@
@@ -17,7 +35,10 @@ import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAV
}} - Poor
- View My Classes
+
diff --git a/bruno-api/Download Export.bru b/bruno-api/Download Export.bru
new file mode 100644
index 0000000..74b90f2
--- /dev/null
+++ b/bruno-api/Download Export.bru
@@ -0,0 +1,11 @@
+meta {
+ name: Download Export
+ type: http
+ seq: 4
+}
+
+get {
+ url: {{base_url}}/classroom-compliance/export
+ body: none
+ auth: inherit
+}