Added file export feature.
This commit is contained in:
parent
4cec4b8328
commit
e8de8e8d59
|
@ -6,6 +6,7 @@ import handy_httpd.components.request : Method;
|
||||||
import api_modules.classroom_compliance.api_class;
|
import api_modules.classroom_compliance.api_class;
|
||||||
import api_modules.classroom_compliance.api_student;
|
import api_modules.classroom_compliance.api_student;
|
||||||
import api_modules.classroom_compliance.api_entry;
|
import api_modules.classroom_compliance.api_entry;
|
||||||
|
import api_modules.classroom_compliance.api_export;
|
||||||
|
|
||||||
void registerApiEndpoints(PathHandler handler) {
|
void registerApiEndpoints(PathHandler handler) {
|
||||||
const ROOT_PATH = "/api/classroom-compliance";
|
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.GET, CLASS_PATH ~ "/entries", &getEntries);
|
||||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries);
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries);
|
||||||
|
|
||||||
|
handler.addMapping(Method.GET, ROOT_PATH ~ "/export", &getFullExport);
|
||||||
}
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { APIClient, type APIResponse, type AuthStoreType } from './base'
|
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_COMPLIANT = '📱'
|
||||||
export const EMOJI_PHONE_NONCOMPLIANT = '📵'
|
export const EMOJI_PHONE_NONCOMPLIANT = '📵'
|
||||||
|
|
|
@ -1,6 +1,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT } from '@/api/classroom_compliance';
|
import { BASE_URL, EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT } from '@/api/classroom_compliance';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
async function downloadExport() {
|
||||||
|
const resp = await fetch(BASE_URL + '/export', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic ' + authStore.getBasicAuth()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!resp.ok) return
|
||||||
|
const url = window.URL.createObjectURL(await resp.blob())
|
||||||
|
const tempLink = document.createElement('a')
|
||||||
|
tempLink.href = url
|
||||||
|
tempLink.download = "classroom-compliance-export.csv"
|
||||||
|
document.body.appendChild(tempLink)
|
||||||
|
tempLink.click()
|
||||||
|
tempLink.remove()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
|
@ -17,7 +35,10 @@ import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAV
|
||||||
}} - Poor</li>
|
}} - Poor</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<RouterLink to="/classroom-compliance">View My Classes</RouterLink>
|
<div class="button-bar">
|
||||||
|
<RouterLink to="/classroom-compliance">View My Classes</RouterLink>
|
||||||
|
<a @click.prevent="downloadExport()" href="#">Export All Data</a>
|
||||||
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
meta {
|
||||||
|
name: Download Export
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/classroom-compliance/export
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
Loading…
Reference in New Issue