From e8de8e8d59ddc7b559c7f6ec59c7c230aa833a8a Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Fri, 7 Feb 2025 12:38:02 -0500 Subject: [PATCH] Added file export feature. --- .../api_modules/classroom_compliance/api.d | 3 + .../classroom_compliance/api_export.d | 153 ++++++++++++++++++ app/src/api/classroom_compliance.ts | 2 +- .../apps/classroom_compliance/MainView.vue | 25 ++- bruno-api/Download Export.bru | 11 ++ 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 api/source/api_modules/classroom_compliance/api_export.d create mode 100644 bruno-api/Download Export.bru 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 @@