Added changes for new school year.
This commit is contained in:
parent
2c08f1bdbd
commit
6177943db7
|
@ -6,8 +6,9 @@ CREATE TABLE classroom_compliance_class (
|
|||
user_id BIGINT NOT NULL
|
||||
REFERENCES auth_user(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * phone + 0.5 * behavior',
|
||||
score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * classroom_readiness + 0.5 * behavior',
|
||||
score_period VARCHAR(64) NOT NULL DEFAULT 'week',
|
||||
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT unique_class_numbers_per_school_year
|
||||
UNIQUE(number, school_year, user_id)
|
||||
);
|
||||
|
@ -36,15 +37,24 @@ CREATE TABLE classroom_compliance_entry (
|
|||
absent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
comment VARCHAR(2000) NOT NULL DEFAULT '',
|
||||
phone_compliant BOOLEAN NULL DEFAULT NULL,
|
||||
classroom_readiness BOOLEAN NULL DEFAULT NULL,
|
||||
behavior_rating INT NULL DEFAULT NULL,
|
||||
CONSTRAINT absence_nulls_check CHECK (
|
||||
(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)
|
||||
(absent AND classroom_readiness IS NULL AND behavior_rating IS NULL) OR
|
||||
(NOT absent AND classroom_readiness IS NOT NULL AND behavior_rating IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT unique_entry_per_date
|
||||
UNIQUE(class_id, student_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE classroom_compliance_entry_comment_checklist (
|
||||
entry_id BIGINT NOT NULL
|
||||
REFERENCES classroom_compliance_entry(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
item VARCHAR(2000) NOT NULL,
|
||||
PRIMARY KEY (entry_id, item)
|
||||
);
|
||||
|
||||
CREATE TABLE classroom_compliance_class_note (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
class_id BIGINT NOT NULL
|
||||
|
|
|
@ -10,9 +10,9 @@ import data_utils;
|
|||
import api_modules.auth : getAdminUserOrThrow;
|
||||
|
||||
struct Announcement {
|
||||
const ulong id;
|
||||
const string type;
|
||||
const string message;
|
||||
ulong id;
|
||||
string type;
|
||||
string message;
|
||||
|
||||
static Announcement parse(DataSetReader r) {
|
||||
return Announcement(
|
||||
|
|
|
@ -13,12 +13,12 @@ import db;
|
|||
import data_utils;
|
||||
|
||||
struct User {
|
||||
const ulong id;
|
||||
const string username;
|
||||
const string passwordHash;
|
||||
const ulong createdAt;
|
||||
const bool isLocked;
|
||||
const bool isAdmin;
|
||||
ulong id;
|
||||
string username;
|
||||
string passwordHash;
|
||||
ulong createdAt;
|
||||
bool isLocked;
|
||||
bool isAdmin;
|
||||
|
||||
static User parse(DataSetReader r) {
|
||||
return User(
|
||||
|
|
|
@ -16,6 +16,7 @@ void registerApiEndpoints(PathHandler handler) {
|
|||
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 ~ "/archived", &setArchived);
|
||||
handler.addMapping(Method.GET, CLASS_PATH ~ "/notes", &getClassNotes);
|
||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
|
||||
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);
|
||||
|
|
|
@ -48,7 +48,7 @@ void getClasses(ref HttpRequestContext ctx) {
|
|||
User user = getUserOrThrow(ctx, conn);
|
||||
const query = "
|
||||
SELECT
|
||||
c.id, c.number, c.school_year,
|
||||
c.id, c.number, c.school_year, c.archived,
|
||||
COUNT(DISTINCT s.id) AS student_count,
|
||||
COUNT(DISTINCT e.id) AS entry_count,
|
||||
MAX(e.date) AS last_entry_date
|
||||
|
@ -63,6 +63,7 @@ void getClasses(ref HttpRequestContext ctx) {
|
|||
ulong id;
|
||||
ushort number;
|
||||
string schoolYear;
|
||||
bool archived;
|
||||
uint studentCount;
|
||||
uint entryCount;
|
||||
string lastEntryDate;
|
||||
|
@ -74,9 +75,10 @@ void getClasses(ref HttpRequestContext ctx) {
|
|||
r.getUlong(1),
|
||||
r.getUshort(2),
|
||||
r.getString(3),
|
||||
r.getUint(4),
|
||||
r.getBoolean(4),
|
||||
r.getUint(5),
|
||||
r.getDate(6).toISOExtString()
|
||||
r.getUint(6),
|
||||
r.getDate(7).toISOExtString()
|
||||
),
|
||||
user.id
|
||||
);
|
||||
|
@ -96,6 +98,7 @@ void deleteClass(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
const query = "DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?";
|
||||
PreparedStatement ps = conn.prepareStatement(query);
|
||||
scope(exit) ps.close();
|
||||
|
@ -119,6 +122,7 @@ void createClassNote(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
struct Payload {string content;}
|
||||
Payload payload = readJsonPayload!(Payload)(ctx);
|
||||
const query = "INSERT INTO classroom_compliance_class_note (class_id, content) VALUES (?, ?) RETURNING id";
|
||||
|
@ -137,6 +141,7 @@ void deleteClassNote(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
|
||||
const query = "DELETE FROM classroom_compliance_class_note WHERE class_id = ? AND id = ?";
|
||||
update(conn, query, cls.id, noteId);
|
||||
|
@ -147,10 +152,20 @@ void resetStudentDesks(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?";
|
||||
update(conn, query, cls.id);
|
||||
}
|
||||
|
||||
void setArchived(ref HttpRequestContext ctx) {
|
||||
Connection conn = getDb();
|
||||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
const query = "UPDATE classroom_compliance_class SET archived = ? WHERE id = ?";
|
||||
update(conn, query, !cls.archived, cls.id);
|
||||
}
|
||||
|
||||
void updateScoreParameters(ref HttpRequestContext ctx) {
|
||||
import api_modules.classroom_compliance.score_eval : validateExpression;
|
||||
import std.algorithm : canFind;
|
||||
|
@ -158,6 +173,7 @@ void updateScoreParameters(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
struct Payload {
|
||||
string scoreExpression;
|
||||
string scorePeriod;
|
||||
|
|
|
@ -22,7 +22,9 @@ struct EntriesTableEntry {
|
|||
ulong createdAt;
|
||||
bool absent;
|
||||
string comment;
|
||||
string[] checklistItems;
|
||||
Optional!bool phoneCompliant;
|
||||
Optional!bool classroomReadiness;
|
||||
Optional!ubyte behaviorRating;
|
||||
|
||||
JSONValue toJsonObj() const {
|
||||
|
@ -32,12 +34,21 @@ struct EntriesTableEntry {
|
|||
obj.object["createdAt"] = JSONValue(createdAt);
|
||||
obj.object["absent"] = JSONValue(absent);
|
||||
obj.object["comment"] = JSONValue(comment);
|
||||
obj.object["checklistItems"] = JSONValue.emptyArray;
|
||||
foreach (ck; checklistItems) {
|
||||
obj.object["checklistItems"].array ~= JSONValue(ck);
|
||||
}
|
||||
if (absent) {
|
||||
obj.object["phoneCompliant"] = JSONValue(null);
|
||||
obj.object["classroomReadiness"] = JSONValue(null);
|
||||
obj.object["behaviorRating"] = JSONValue(null);
|
||||
} else {
|
||||
obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value);
|
||||
obj.object["behaviorRating"] = JSONValue(behaviorRating.value);
|
||||
JSONValue pcValue = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value);
|
||||
JSONValue crValue = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value);
|
||||
JSONValue bValue = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value);
|
||||
obj.object["phoneCompliant"] = pcValue;
|
||||
obj.object["classroomReadiness"] = crValue;
|
||||
obj.object["behaviorRating"] = bValue;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
@ -125,7 +136,14 @@ void getEntries(ref HttpRequestContext ctx) {
|
|||
entry.created_at,
|
||||
entry.absent,
|
||||
entry.comment,
|
||||
(
|
||||
SELECT STRING_AGG(ck.item, '|||')
|
||||
FROM classroom_compliance_entry_comment_checklist ck
|
||||
WHERE ck.entry_id = entry.id
|
||||
|
||||
),
|
||||
entry.phone_compliant,
|
||||
entry.classroom_readiness,
|
||||
entry.behavior_rating,
|
||||
student.id,
|
||||
student.name,
|
||||
|
@ -155,25 +173,34 @@ void getEntries(ref HttpRequestContext ctx) {
|
|||
const absent = r.getBoolean(4);
|
||||
Optional!bool phoneCompliant = absent
|
||||
? Optional!bool.empty
|
||||
: Optional!bool.of(r.getBoolean(6));
|
||||
: Optional!bool.of(r.getBoolean(7));
|
||||
if (r.isNull(7)) phoneCompliant.isNull = true;
|
||||
Optional!bool classroomReadiness = absent
|
||||
? Optional!bool.empty
|
||||
: Optional!bool.of(r.getBoolean(8));
|
||||
if (r.isNull(8)) classroomReadiness.isNull = true;
|
||||
Optional!ubyte behaviorRating = absent
|
||||
? Optional!ubyte.empty
|
||||
: Optional!ubyte.of(r.getUbyte(7));
|
||||
: Optional!ubyte.of(r.getUbyte(9));
|
||||
if (r.isNull(9)) behaviorRating.isNull = true;
|
||||
import std.string : split;
|
||||
EntriesTableEntry entryData = EntriesTableEntry(
|
||||
r.getUlong(1),
|
||||
r.getDate(2),
|
||||
r.getUlong(3),
|
||||
r.getBoolean(4),
|
||||
r.getString(5),
|
||||
r.getString(6).split("|||"),
|
||||
phoneCompliant,
|
||||
classroomReadiness,
|
||||
behaviorRating
|
||||
);
|
||||
ClassroomComplianceStudent student = ClassroomComplianceStudent(
|
||||
r.getUlong(8),
|
||||
r.getString(9),
|
||||
r.getUlong(12),
|
||||
r.getUshort(10),
|
||||
r.getBoolean(11)
|
||||
r.getUlong(10),
|
||||
r.getString(11),
|
||||
r.getUlong(14),
|
||||
r.getUshort(12),
|
||||
r.getBoolean(13)
|
||||
);
|
||||
string dateStr = entryData.date.toISOExtString();
|
||||
|
||||
|
@ -252,6 +279,7 @@ void saveEntries(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
JSONValue bodyContent = ctx.request.readBodyAsJson();
|
||||
try {
|
||||
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
|
||||
|
@ -265,7 +293,16 @@ void saveEntries(ref HttpRequestContext ctx) {
|
|||
|
||||
Optional!ClassroomComplianceEntry existingEntry = findOne(
|
||||
conn,
|
||||
"SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
||||
"SELECT id, class_id, student_id, date, created_at,
|
||||
absent, comment,
|
||||
(
|
||||
SELECT STRING_AGG(ck.item, '|||')
|
||||
FROM classroom_compliance_entry_comment_checklist ck
|
||||
WHERE ck.entry_id = id
|
||||
),
|
||||
phone_compliant, classroom_readiness, behavior_rating
|
||||
FROM classroom_compliance_entry
|
||||
WHERE class_id = ? AND student_id = ? AND date = ?",
|
||||
&ClassroomComplianceEntry.parse,
|
||||
cls.id, studentId, dateStr
|
||||
);
|
||||
|
@ -333,17 +370,25 @@ private void insertNewEntry(
|
|||
bool absent = payload.object["absent"].boolean;
|
||||
string comment = payload.object["comment"].str;
|
||||
if (comment is null) comment = "";
|
||||
Optional!bool phoneCompliant = Optional!bool.empty;
|
||||
string[] checklistItems;
|
||||
if ("checklistItems" in payload.object) {
|
||||
checklistItems = payload.object["checklistItems"].array
|
||||
.map!(v => v.str)
|
||||
.array;
|
||||
}
|
||||
Optional!bool classroomReadiness = Optional!bool.empty;
|
||||
Optional!ubyte behaviorRating = Optional!ubyte.empty;
|
||||
if (!absent) {
|
||||
phoneCompliant = Optional!bool.of(payload.object["phoneCompliant"].boolean);
|
||||
classroomReadiness = Optional!bool.of(payload.object["classroomReadiness"].boolean);
|
||||
behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer);
|
||||
}
|
||||
// Do the main insert first.
|
||||
import std.variant;
|
||||
Variant newEntryId;
|
||||
const query = "
|
||||
INSERT INTO classroom_compliance_entry
|
||||
(class_id, student_id, date, absent, comment, phone_compliant, behavior_rating)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
(class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id";
|
||||
PreparedStatement ps = conn.prepareStatement(query);
|
||||
scope(exit) ps.close();
|
||||
ps.setUlong(1, classId);
|
||||
|
@ -355,10 +400,11 @@ private void insertNewEntry(
|
|||
ps.setNull(6);
|
||||
ps.setNull(7);
|
||||
} else {
|
||||
ps.setBoolean(6, phoneCompliant.value);
|
||||
ps.setBoolean(6, classroomReadiness.value);
|
||||
ps.setUbyte(7, behaviorRating.value);
|
||||
}
|
||||
ps.executeUpdate();
|
||||
ps.executeUpdate(newEntryId);
|
||||
updateEntryCommentChecklistItems(conn, newEntryId.coerce!ulong, checklistItems);
|
||||
|
||||
infoF!"Created new entry for student %d: %s"(studentId, payload);
|
||||
}
|
||||
|
@ -374,15 +420,21 @@ private void updateEntry(
|
|||
bool absent = obj.object["absent"].boolean;
|
||||
string comment = obj.object["comment"].str;
|
||||
if (comment is null) comment = "";
|
||||
Optional!bool phoneCompliant = Optional!bool.empty;
|
||||
string[] checklistItems;
|
||||
if ("checklistItems" in obj.object) {
|
||||
checklistItems = obj.object["checklistItems"].array
|
||||
.map!(v => v.str)
|
||||
.array;
|
||||
}
|
||||
Optional!bool classroomReadiness = Optional!bool.empty;
|
||||
Optional!ubyte behaviorRating = Optional!ubyte.empty;
|
||||
if (!absent) {
|
||||
phoneCompliant = Optional!bool.of(obj.object["phoneCompliant"].boolean);
|
||||
classroomReadiness = Optional!bool.of(obj.object["classroomReadiness"].boolean);
|
||||
behaviorRating = Optional!ubyte.of(cast(ubyte) obj.object["behaviorRating"].integer);
|
||||
}
|
||||
const query = "
|
||||
UPDATE classroom_compliance_entry
|
||||
SET absent = ?, comment = ?, phone_compliant = ?, behavior_rating = ?
|
||||
SET absent = ?, comment = ?, classroom_readiness = ?, behavior_rating = ?
|
||||
WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?
|
||||
";
|
||||
PreparedStatement ps = conn.prepareStatement(query);
|
||||
|
@ -393,7 +445,7 @@ private void updateEntry(
|
|||
ps.setNull(3);
|
||||
ps.setNull(4);
|
||||
} else {
|
||||
ps.setBoolean(3, phoneCompliant.value);
|
||||
ps.setBoolean(3, classroomReadiness.value);
|
||||
ps.setUbyte(4, behaviorRating.value);
|
||||
}
|
||||
ps.setUlong(5, classId);
|
||||
|
@ -401,6 +453,27 @@ private void updateEntry(
|
|||
ps.setString(7, dateStr);
|
||||
ps.setUlong(8, entryId);
|
||||
ps.executeUpdate();
|
||||
updateEntryCommentChecklistItems(conn, entryId, checklistItems);
|
||||
|
||||
infoF!"Updated entry %d"(entryId);
|
||||
}
|
||||
|
||||
private void updateEntryCommentChecklistItems(Connection conn, ulong entryId, string[] items) {
|
||||
PreparedStatement ps1 = conn.prepareStatement(
|
||||
"DELETE FROM classroom_compliance_entry_comment_checklist WHERE entry_id = ?"
|
||||
);
|
||||
scope(exit) ps1.close();
|
||||
ps1.setUlong(1, entryId);
|
||||
ps1.executeUpdate();
|
||||
const checklistItemsQuery = "
|
||||
INSERT INTO classroom_compliance_entry_comment_checklist (entry_id, item)
|
||||
VALUES (?, ?)
|
||||
";
|
||||
PreparedStatement ps2 = conn.prepareStatement(checklistItemsQuery);
|
||||
scope(exit) ps2.close();
|
||||
foreach (item; items) {
|
||||
ps2.setUlong(1, entryId);
|
||||
ps2.setString(2, item);
|
||||
ps2.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ void getFullExport(ref HttpRequestContext ctx) {
|
|||
e.created_at AS entry_created_at,
|
||||
e.absent AS absent,
|
||||
e.phone_compliant AS phone_compliant,
|
||||
e.classroom_readiness AS classroom_readiness,
|
||||
e.behavior_rating AS behavior_rating,
|
||||
e.comment AS comment
|
||||
FROM classroom_compliance_class c
|
||||
|
@ -62,6 +63,7 @@ void getFullExport(ref HttpRequestContext ctx) {
|
|||
CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))),
|
||||
CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"),
|
||||
CSVColumnDef("Phone Compliant", &formatPhoneCompliance),
|
||||
CSVColumnDef("Classroom Readiness", &formatClassroomReadiness),
|
||||
CSVColumnDef("Behavior Rating", &formatBehaviorRating),
|
||||
CSVColumnDef("Comment", &formatComment)
|
||||
];
|
||||
|
@ -95,6 +97,11 @@ private string formatPhoneCompliance(DataSetReader r, int i) {
|
|||
return r.getBoolean(i) ? "Compliant" : "Non-compliant";
|
||||
}
|
||||
|
||||
private string formatClassroomReadiness(DataSetReader r, int i) {
|
||||
if (r.isNull(i)) return "N/A";
|
||||
return r.getBoolean(i) ? "Ready" : "Not Ready";
|
||||
}
|
||||
|
||||
private string formatBehaviorRating(DataSetReader r, int i) {
|
||||
if (r.isNull(i)) return "N/A";
|
||||
ubyte score = r.getUbyte(i);
|
||||
|
|
|
@ -15,6 +15,7 @@ void createStudent(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
struct StudentPayload {
|
||||
string name;
|
||||
ushort deskNumber;
|
||||
|
@ -83,6 +84,8 @@ void updateStudent(ref HttpRequestContext ctx) {
|
|||
Connection conn = getDb();
|
||||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
auto student = getStudentOrThrow(ctx, conn, user);
|
||||
struct StudentUpdatePayload {
|
||||
string name;
|
||||
|
@ -139,6 +142,8 @@ void deleteStudent(ref HttpRequestContext ctx) {
|
|||
Connection conn = getDb();
|
||||
scope(exit) conn.close();
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
auto student = getStudentOrThrow(ctx, conn, user);
|
||||
update(
|
||||
conn,
|
||||
|
@ -154,7 +159,18 @@ void getStudentEntries(ref HttpRequestContext ctx) {
|
|||
auto student = getStudentOrThrow(ctx, conn, user);
|
||||
auto entries = findAll(
|
||||
conn,
|
||||
"SELECT * FROM classroom_compliance_entry WHERE student_id = ? ORDER BY date DESC",
|
||||
"SELECT id, class_id, student_id, date, created_at,
|
||||
absent, comment,
|
||||
(
|
||||
SELECT STRING_AGG(ck.item, '|||')
|
||||
FROM classroom_compliance_entry_comment_checklist ck
|
||||
WHERE ck.entry_id = id
|
||||
),
|
||||
phone_compliant, classroom_readiness, behavior_rating
|
||||
FROM classroom_compliance_entry
|
||||
WHERE student_id = ?
|
||||
GROUP BY classroom_compliance_entry.id
|
||||
ORDER BY date DESC",
|
||||
&ClassroomComplianceEntry.parse,
|
||||
student.id
|
||||
);
|
||||
|
@ -221,6 +237,8 @@ void moveStudentToOtherClass(ref HttpRequestContext ctx) {
|
|||
scope(exit) conn.close();
|
||||
conn.setAutoCommit(false);
|
||||
User user = getUserOrThrow(ctx, conn);
|
||||
auto cls = getClassOrThrow(ctx, conn, user);
|
||||
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
|
||||
auto student = getStudentOrThrow(ctx, conn, user);
|
||||
struct Payload {
|
||||
ulong classId;
|
||||
|
|
|
@ -4,20 +4,23 @@ import ddbc : DataSetReader;
|
|||
import handy_httpd.components.optional;
|
||||
import std.json;
|
||||
import std.datetime;
|
||||
import std.string : split;
|
||||
|
||||
struct ClassroomComplianceClass {
|
||||
const ulong id;
|
||||
const ushort number;
|
||||
const string schoolYear;
|
||||
const ulong userId;
|
||||
const string scoreExpression;
|
||||
const string scorePeriod;
|
||||
ulong id;
|
||||
ushort number;
|
||||
string schoolYear;
|
||||
bool archived;
|
||||
ulong userId;
|
||||
string scoreExpression;
|
||||
string scorePeriod;
|
||||
|
||||
static ClassroomComplianceClass parse(DataSetReader r) {
|
||||
return ClassroomComplianceClass(
|
||||
r.getUlong(1),
|
||||
r.getUshort(2),
|
||||
r.getString(3),
|
||||
r.getBoolean(7),
|
||||
r.getUlong(4),
|
||||
r.getString(5),
|
||||
r.getString(6)
|
||||
|
@ -26,11 +29,11 @@ struct ClassroomComplianceClass {
|
|||
}
|
||||
|
||||
struct ClassroomComplianceStudent {
|
||||
const ulong id;
|
||||
const string name;
|
||||
const ulong classId;
|
||||
const ushort deskNumber;
|
||||
const bool removed;
|
||||
ulong id;
|
||||
string name;
|
||||
ulong classId;
|
||||
ushort deskNumber;
|
||||
bool removed;
|
||||
|
||||
static ClassroomComplianceStudent parse(DataSetReader r) {
|
||||
return ClassroomComplianceStudent(
|
||||
|
@ -44,35 +47,32 @@ struct ClassroomComplianceStudent {
|
|||
}
|
||||
|
||||
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;
|
||||
ulong id;
|
||||
ulong classId;
|
||||
ulong studentId;
|
||||
Date date;
|
||||
ulong createdAt;
|
||||
bool absent;
|
||||
string comment;
|
||||
string[] checklistItems;
|
||||
Optional!bool phoneCompliant;
|
||||
Optional!bool classroomReadiness;
|
||||
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
|
||||
);
|
||||
ClassroomComplianceEntry entry;
|
||||
entry.id = r.getUlong(1);
|
||||
entry.classId = r.getUlong(2);
|
||||
entry.studentId = r.getUlong(3);
|
||||
entry.date = r.getDate(4);
|
||||
entry.createdAt = r.getUlong(5);
|
||||
entry.absent = r.getBoolean(6);
|
||||
entry.comment = r.getString(7);
|
||||
entry.checklistItems = r.getString(8).split("|||");
|
||||
entry.phoneCompliant = r.isNull(9) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(9));
|
||||
entry.classroomReadiness = r.isNull(10) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(10));
|
||||
entry.behaviorRating = r.isNull(11) ? Optional!ubyte.empty : Optional!ubyte.of(r.getUbyte(11));
|
||||
return entry;
|
||||
}
|
||||
|
||||
JSONValue toJsonObj() const {
|
||||
|
@ -84,28 +84,22 @@ struct ClassroomComplianceEntry {
|
|||
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);
|
||||
obj.object["checklistItems"] = JSONValue.emptyArray;
|
||||
foreach (item; checklistItems) {
|
||||
obj.object["checklistItems"].array ~= JSONValue(item);
|
||||
}
|
||||
obj.object["phoneCompliant"] = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value);
|
||||
obj.object["classroomReadiness"] = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value);
|
||||
obj.object["behaviorRating"] = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value);
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
struct ClassroomComplianceClassNote {
|
||||
const ulong id;
|
||||
const ulong classId;
|
||||
const ulong createdAt;
|
||||
const string content;
|
||||
ulong id;
|
||||
ulong classId;
|
||||
ulong createdAt;
|
||||
string content;
|
||||
|
||||
static ClassroomComplianceClassNote parse(DataSetReader r) {
|
||||
return ClassroomComplianceClassNote(
|
||||
|
|
|
@ -15,7 +15,7 @@ enum ScorePeriod : string {
|
|||
|
||||
private struct ScoringParameters {
|
||||
Expr expr;
|
||||
const DateRange dateRange;
|
||||
DateRange dateRange;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -54,6 +54,7 @@ Optional!double[ulong] getScores(
|
|||
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 classroom_readiness = FALSE THEN 1 ELSE 0 END) AS not_classroom_ready_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
|
||||
|
@ -74,14 +75,16 @@ Optional!double[ulong] getScores(
|
|||
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);
|
||||
uint notClassroomReadyCount = r.getUint(5);
|
||||
uint behaviorGoodCount = r.getUint(6);
|
||||
uint behaviorMediocreCount = r.getUint(7);
|
||||
uint behaviorPoorCount = r.getUint(8);
|
||||
scores[studentId] = calculateScore(
|
||||
params.expr,
|
||||
entryCount,
|
||||
absenceCount,
|
||||
phoneNonComplianceCount,
|
||||
notClassroomReadyCount,
|
||||
behaviorGoodCount,
|
||||
behaviorMediocreCount,
|
||||
behaviorPoorCount
|
||||
|
@ -132,6 +135,7 @@ private Optional!double calculateScore(
|
|||
uint entryCount,
|
||||
uint absenceCount,
|
||||
uint phoneNonComplianceCount,
|
||||
uint notClassroomReadyCount,
|
||||
uint behaviorGoodCount,
|
||||
uint behaviorMediocreCount,
|
||||
uint behaviorPoorCount
|
||||
|
@ -151,6 +155,14 @@ private Optional!double calculateScore(
|
|||
phoneCompliantCount = presentCount - phoneNonComplianceCount;
|
||||
}
|
||||
double phoneScore = phoneCompliantCount / cast(double) presentCount;
|
||||
// Classroom readiness score:
|
||||
uint classroomReadyCount;
|
||||
if (presentCount < notClassroomReadyCount) {
|
||||
classroomReadyCount = 0;
|
||||
} else {
|
||||
classroomReadyCount = presentCount - notClassroomReadyCount;
|
||||
}
|
||||
double classroomReadinessScore = classroomReadyCount / cast(double) presentCount;
|
||||
|
||||
double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
|
||||
double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
|
||||
|
@ -159,6 +171,7 @@ private Optional!double calculateScore(
|
|||
|
||||
return Optional!double.of(scoreExpression.eval([
|
||||
"phone": phoneScore,
|
||||
"classroom_readiness": classroomReadinessScore,
|
||||
"behavior": typicalBehaviorScore,
|
||||
"behavior_good": behaviorGoodScore,
|
||||
"behavior_mediocre": behaviorMediocreScore,
|
||||
|
|
|
@ -35,7 +35,8 @@ void initializeSchema() {
|
|||
scope(exit) stmt.close();
|
||||
const string AUTH_SCHEMA = import("schema/auth.sql");
|
||||
const string CLASSROOM_COMPLIANCE_SCHEMA = import("schema/classroom_compliance.sql");
|
||||
const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA];
|
||||
const string ANNOUNCEMENT_SCHEMA = import("schema/announcement.sql");
|
||||
const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA, ANNOUNCEMENT_SCHEMA];
|
||||
uint schemaNumber = 1;
|
||||
foreach (schema; schemas) {
|
||||
infoF!"Intializing schema #%d."(schemaNumber++);
|
||||
|
|
|
@ -137,8 +137,8 @@ void addEntry(
|
|||
) {
|
||||
const entryQuery = "
|
||||
INSERT INTO classroom_compliance_entry
|
||||
(class_id, student_id, date, absent, comment, phone_compliant, behavior_rating)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
(class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
PreparedStatement ps = conn.prepareStatement(entryQuery);
|
||||
scope(exit) ps.close();
|
||||
ps.setUlong(1, classId);
|
||||
|
@ -150,12 +150,13 @@ void addEntry(
|
|||
} else {
|
||||
ps.setString(5, comment);
|
||||
}
|
||||
ps.setNull(6); // Always set phone_compliant as null from now on.
|
||||
if (absent) {
|
||||
ps.setNull(6);
|
||||
ps.setNull(7);
|
||||
ps.setNull(8);
|
||||
} else {
|
||||
ps.setBoolean(6, phoneCompliant);
|
||||
ps.setUint(7, behaviorRating);
|
||||
ps.setBoolean(7, true);
|
||||
ps.setUint(8, behaviorRating);
|
||||
}
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
@ -167,6 +168,7 @@ void deleteAllData(Connection conn) {
|
|||
const tables = [
|
||||
"announcement",
|
||||
"classroom_compliance_class_note",
|
||||
"classroom_compliance_entry_comment_checklist",
|
||||
"classroom_compliance_entry",
|
||||
"classroom_compliance_student",
|
||||
"classroom_compliance_class",
|
||||
|
|
|
@ -4,6 +4,8 @@ export const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
|
|||
|
||||
export const EMOJI_PHONE_COMPLIANT = '📱'
|
||||
export const EMOJI_PHONE_NONCOMPLIANT = '📵'
|
||||
export const EMOJI_CLASSROOM_READY = '🍎'
|
||||
export const EMOJI_NOT_CLASSROOM_READY = '🐛'
|
||||
export const EMOJI_PRESENT = '✅'
|
||||
export const EMOJI_ABSENT = '❌'
|
||||
export const EMOJI_BEHAVIOR_GOOD = '😇'
|
||||
|
@ -16,6 +18,7 @@ export interface Class {
|
|||
schoolYear: string
|
||||
scoreExpression: string
|
||||
scorePeriod: string
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
export interface ClassesResponseClass {
|
||||
|
@ -25,6 +28,7 @@ export interface ClassesResponseClass {
|
|||
studentCount: number
|
||||
entryCount: number
|
||||
lastEntryDate: string
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
export interface Student {
|
||||
|
@ -41,8 +45,10 @@ export interface Entry {
|
|||
createdAt: number
|
||||
absent: boolean
|
||||
phoneCompliant: boolean | null
|
||||
classroomReadiness: boolean | null
|
||||
behaviorRating: number | null
|
||||
comment: string
|
||||
checklistItems: string[]
|
||||
}
|
||||
|
||||
export function getDefaultEntry(dateStr: string): Entry {
|
||||
|
@ -51,9 +57,11 @@ export function getDefaultEntry(dateStr: string): Entry {
|
|||
date: dateStr,
|
||||
createdAt: Date.now(),
|
||||
absent: false,
|
||||
phoneCompliant: true,
|
||||
phoneCompliant: null,
|
||||
classroomReadiness: true,
|
||||
behaviorRating: 3,
|
||||
comment: '',
|
||||
checklistItems: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -152,6 +160,10 @@ export class ClassroomComplianceAPIClient extends APIClient {
|
|||
return new APIResponse(this.handleAPIResponseWithNoBody(promise))
|
||||
}
|
||||
|
||||
toggleArchived(classId: number): APIResponse<void> {
|
||||
return super.postWithNoExpectedResponse(`/classes/${classId}/archived`, {})
|
||||
}
|
||||
|
||||
updateScoreParameters(
|
||||
classId: number,
|
||||
scoreExpression: string,
|
||||
|
|
|
@ -9,7 +9,9 @@ const router = useRouter()
|
|||
</script>
|
||||
<template>
|
||||
<div class="class-item" @click="router.push(`/classroom-compliance/classes/${cls.id}`)">
|
||||
<h3>Class <span v-text="cls.number"></span></h3>
|
||||
<h3>Class <span v-text="cls.number"></span> <span v-text="cls.schoolYear"
|
||||
style="font-weight: lighter; font-size: small;"></span>
|
||||
</h3>
|
||||
<p>
|
||||
{{ cls.studentCount }} students,
|
||||
{{ cls.entryCount }} entries.
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useAuthStore } from '@/stores/auth';
|
|||
|
||||
const props = defineProps<{
|
||||
note: ClassNote
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['noteDeleted'])
|
||||
const authStore = useAuthStore()
|
||||
|
@ -21,7 +22,7 @@ async function deleteNote() {
|
|||
<p class="class-note-item-content">{{ note.content }}</p>
|
||||
<div class="class-note-item-attributes-container">
|
||||
<p class="class-note-item-timestamp">{{ new Date(note.createdAt).toLocaleString() }}</p>
|
||||
<button class="class-note-item-delete-button" @click="deleteNote()">Delete</button>
|
||||
<button class="class-note-item-delete-button" @click="deleteNote()" :disabled="disabled">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -17,6 +17,7 @@ const notes: Ref<ClassNote[]> = ref([])
|
|||
const noteContent: Ref<string> = ref('')
|
||||
const scoreExpression: Ref<string> = ref('')
|
||||
const scorePeriod: Ref<string> = ref('')
|
||||
const archived = computed(() => cls.value !== null && cls.value.archived)
|
||||
const canUpdateScoreParameters = computed(() => {
|
||||
return cls.value && (
|
||||
cls.value.scoreExpression !== scoreExpression.value ||
|
||||
|
@ -53,6 +54,12 @@ async function deleteThisClass() {
|
|||
await router.replace('/classroom-compliance')
|
||||
}
|
||||
|
||||
async function toggleArchived() {
|
||||
if (!cls.value) return
|
||||
await apiClient.toggleArchived(cls.value.id).handleErrorsWithAlertNoBody()
|
||||
loadClass()
|
||||
}
|
||||
|
||||
async function submitNote() {
|
||||
if (noteContent.value.trim().length < 1 || !cls.value) return
|
||||
const note = await apiClient.createClassNote(cls.value?.id, noteContent.value).handleErrorsWithAlert()
|
||||
|
@ -113,22 +120,28 @@ function resetScoreParameters() {
|
|||
<h1 class="align-center" style="margin-bottom: 0;">Class <span v-text="cls.number"></span></h1>
|
||||
<p class="align-center" style="margin-top: 0; margin-bottom: 2em;">For the {{ cls.schoolYear }} school year.</p>
|
||||
<div class="button-bar align-center" style="margin-bottom: 1em;">
|
||||
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)">Add
|
||||
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)"
|
||||
:disabled="archived">Add
|
||||
Student</button>
|
||||
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/import-students`)">Import
|
||||
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/import-students`)"
|
||||
:disabled="archived">Import
|
||||
Students</button>
|
||||
<button type="button" @click="resetStudentDesks">Clear Assigned Desks</button>
|
||||
<button type="button" @click="deleteThisClass">Delete this Class</button>
|
||||
<button type="button" @click="resetStudentDesks" :disabled="archived">Clear Assigned Desks</button>
|
||||
<button type="button" @click="deleteThisClass" :disabled="archived">Delete this Class</button>
|
||||
<button type="button" @click="toggleArchived">
|
||||
<span v-if="archived">Unarchive</span>
|
||||
<span v-if="!archived">Archive</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EntriesTable :classId="cls.id" ref="entries-table" />
|
||||
<EntriesTable :classId="cls.id" :disabled="archived" ref="entries-table" />
|
||||
|
||||
<div>
|
||||
<h3 style="margin-bottom: 0.25em;">Notes</h3>
|
||||
<form @submit.prevent="submitNote">
|
||||
<textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1"
|
||||
v-model="noteContent"></textarea>
|
||||
<button style="vertical-align: top; margin-left: 0.5em;" type="submit">Add Note</button>
|
||||
<textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1" v-model="noteContent"
|
||||
:readonly="archived"></textarea>
|
||||
<button style="vertical-align: top; margin-left: 0.5em;" type="submit" :disabled="archived">Add Note</button>
|
||||
</form>
|
||||
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
|
||||
</div>
|
||||
|
@ -142,7 +155,7 @@ function resetScoreParameters() {
|
|||
<div>
|
||||
<label for="score-expression-input">Expression</label>
|
||||
<textarea id="score-expression-input" v-model="scoreExpression" class="text-mono" minlength="1"
|
||||
maxlength="255" style="min-width: 500px; min-height: 50px;"></textarea>
|
||||
maxlength="255" style="min-width: 500px; min-height: 50px;" :readonly="archived"></textarea>
|
||||
</div>
|
||||
<p class="form-input-hint">
|
||||
The expression to use to calculate each student's score. This should be a simple mathematical expression that
|
||||
|
@ -169,7 +182,7 @@ function resetScoreParameters() {
|
|||
</p>
|
||||
<div>
|
||||
<label for="score-period-select">Period</label>
|
||||
<select v-model="scorePeriod">
|
||||
<select v-model="scorePeriod" :disabled="archived">
|
||||
<option value="week">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -177,8 +190,9 @@ function resetScoreParameters() {
|
|||
The period over which to calculate scores.
|
||||
</p>
|
||||
<div class="button-bar">
|
||||
<button type="submit" :disabled="!canUpdateScoreParameters">Save</button>
|
||||
<button type="button" :disabled="!canUpdateScoreParameters" @click="resetScoreParameters">Reset</button>
|
||||
<button type="submit" :disabled="!canUpdateScoreParameters || archived">Save</button>
|
||||
<button type="button" :disabled="!canUpdateScoreParameters || archived"
|
||||
@click="resetScoreParameters">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ClassroomComplianceAPIClient, type ClassesResponseClass } from '@/api/classroom_compliance'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { type Ref, ref, onMounted } from 'vue'
|
||||
import { type Ref, ref, onMounted, computed } from 'vue'
|
||||
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const classes: Ref<ClassesResponseClass[]> = ref([])
|
||||
const archivedClasses = computed(() => classes.value.filter(c => c.archived))
|
||||
const activeClasses = computed(() => classes.value.filter(c => !c.archived))
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
@ -23,7 +25,11 @@ onMounted(() => {
|
|||
<button type="button" @click="router.push('/classroom-compliance/edit-class')">Add Class</button>
|
||||
</div>
|
||||
<div>
|
||||
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
||||
<ClassItem v-for="cls in activeClasses" :key="cls.id" :cls="cls" />
|
||||
</div>
|
||||
<div v-if="archivedClasses.length > 0">
|
||||
<h3 style="text-align: center;">Archived Classes</h3>
|
||||
<ClassItem v-for="cls in archivedClasses" :key="cls.id" :cls="cls" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -16,7 +16,8 @@ import StudentNameCell from '@/apps/classroom_compliance/entries_table/StudentNa
|
|||
|
||||
const authStore = useAuthStore()
|
||||
const props = defineProps<{
|
||||
classId: number
|
||||
classId: number,
|
||||
disabled: boolean
|
||||
}>()
|
||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||
|
||||
|
@ -160,6 +161,11 @@ async function showNextWeek() {
|
|||
}
|
||||
|
||||
async function saveEdits() {
|
||||
if (props.disabled) {
|
||||
console.warn('Cannot save edits when disabled.')
|
||||
await loadEntries()
|
||||
return
|
||||
}
|
||||
if (!lastSaveState.value) {
|
||||
console.warn('No lastSaveState, cannot determine what edits were made.')
|
||||
await loadEntries()
|
||||
|
@ -237,6 +243,7 @@ function getVisibleStudentEntries(student: EntriesResponseStudent): Record<strin
|
|||
}
|
||||
|
||||
function addAllEntriesForDate(dateStr: string) {
|
||||
if (props.disabled) return
|
||||
for (let i = 0; i < students.value.length; i++) {
|
||||
const student = students.value[i]
|
||||
if (student.removed) continue
|
||||
|
@ -293,7 +300,7 @@ defineExpose({
|
|||
<th v-if="selectedView !== TableView.WHITEBOARD">#</th>
|
||||
<th>Student</th>
|
||||
<th v-if="assignedDesks">Desk</th>
|
||||
<DateHeaderCell v-for="date in getVisibleDates()" :key="date" :date-str="date"
|
||||
<DateHeaderCell v-for="date in getVisibleDates()" :key="date" :date-str="date" :disabled="disabled"
|
||||
@add-all-entries-clicked="addAllEntriesForDate(date)" />
|
||||
<th v-if="selectedView !== TableView.WHITEBOARD">Score</th>
|
||||
</tr>
|
||||
|
@ -305,7 +312,8 @@ defineExpose({
|
|||
<StudentNameCell :student="student" :class-id="classId" />
|
||||
<td v-if="assignedDesks" v-text="student.deskNumber"></td>
|
||||
<EntryTableCell v-for="(entry, date) in getVisibleStudentEntries(student)" :key="date"
|
||||
v-model="student.entries[date]" :date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp" />
|
||||
v-model="student.entries[date]" :date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp"
|
||||
:disabled="disabled" />
|
||||
<StudentScoreCell :score="student.score" v-if="selectedView !== TableView.WHITEBOARD" />
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, type Entry } from '@/api/classroom_compliance';
|
||||
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, type Entry } from '@/api/classroom_compliance';
|
||||
|
||||
const props = defineProps<{
|
||||
entry: Entry
|
||||
}>()
|
||||
|
||||
function getFormattedDate() {
|
||||
const d = new Date(props.entry.date)
|
||||
const d = new Date(props.entry.date + 'T00:00:00')
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return days[d.getDay()] + ', ' + d.toLocaleDateString()
|
||||
}
|
||||
|
@ -21,6 +21,9 @@ function getFormattedDate() {
|
|||
<span v-if="entry.phoneCompliant === true">{{ EMOJI_PHONE_COMPLIANT }}</span>
|
||||
<span v-if="entry.phoneCompliant === false">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
|
||||
|
||||
<span v-if="entry.classroomReadiness === true">{{ EMOJI_CLASSROOM_READY }}</span>
|
||||
<span v-if="entry.classroomReadiness === false">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
|
||||
|
||||
<span v-if="entry.behaviorRating === 3">{{ EMOJI_BEHAVIOR_GOOD }}</span>
|
||||
<span v-if="entry.behaviorRating === 2">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
|
||||
<span v-if="entry.behaviorRating === 1">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
||||
|
@ -28,6 +31,9 @@ function getFormattedDate() {
|
|||
<p v-if="entry.comment.trim().length > 0" class="comment">
|
||||
{{ entry.comment }}
|
||||
</p>
|
||||
<ul v-if="entry.checklistItems">
|
||||
<li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { ClassroomComplianceAPIClient, type Class, type Entry, type Student, type StudentStatisticsOverview } from '@/api/classroom_compliance'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import StudentEntriesList from './StudentEntriesList.vue'
|
||||
import { APIError } from '@/api/base'
|
||||
|
@ -18,9 +18,38 @@ const cls: Ref<Class | null> = ref(null)
|
|||
const student: Ref<Student | null> = ref(null)
|
||||
const entries: Ref<Entry[]> = ref([])
|
||||
const statistics: Ref<StudentStatisticsOverview | null> = ref(null)
|
||||
// Filtered set of entries for "last week", used in the week overview dialog.
|
||||
const lastWeeksEntries = computed(() => {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
const toDate = new Date()
|
||||
if (now.getDay() >= 1 && now.getDay() <= 5) {
|
||||
// If we're currently in a week, shift the to-date to the Friday of this week.
|
||||
const dayDiff = 5 - now.getDay()
|
||||
toDate.setDate(now.getDate() + dayDiff)
|
||||
} else {
|
||||
// If it's saturday or sunday, shift back to the previous Friday.
|
||||
if (now.getDay() === 6) {
|
||||
toDate.setDate(now.getDate() - 1)
|
||||
} else {
|
||||
toDate.setDate(now.getDate() - 2)
|
||||
}
|
||||
}
|
||||
|
||||
const fromDate = new Date()
|
||||
fromDate.setHours(0, 0, 0, 0)
|
||||
fromDate.setDate(toDate.getDate() - 4)
|
||||
|
||||
const filtered = entries.value
|
||||
.filter(e => new Date(e.date + 'T00:00:00').getTime() >= fromDate.getTime() &&
|
||||
new Date(e.date + 'T00:00:00').getTime() <= toDate.getTime())
|
||||
filtered.sort((a, b) => new Date(b.date + 'T00:00:00').getTime() - new Date(a.date + 'T00:00:00').getTime())
|
||||
return filtered
|
||||
})
|
||||
|
||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||
const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog')
|
||||
const weekOverviewDialog = useTemplateRef('weekOverviewDialog')
|
||||
onMounted(async () => {
|
||||
const classIdNumber = parseInt(props.classId, 10)
|
||||
cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert()
|
||||
|
@ -58,6 +87,12 @@ async function deleteThisStudent() {
|
|||
await apiClient.deleteStudent(cls.value.id, student.value.id)
|
||||
await router.replace(`/classroom-compliance/classes/${cls.value.id}`)
|
||||
}
|
||||
|
||||
function getFormattedDate(entry: Entry) {
|
||||
const d = new Date(entry.date + 'T00:00:00')
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return days[d.getDay()] + ', ' + d.toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="student" class="centered-content">
|
||||
|
@ -73,6 +108,7 @@ async function deleteThisStudent() {
|
|||
<button type="button"
|
||||
@click="router.push(`/classroom-compliance/classes/${student.classId}/edit-student?studentId=${student.id}`)">Edit</button>
|
||||
<button type="button" @click="deleteThisStudent">Delete</button>
|
||||
<button type="button" @click="weekOverviewDialog?.showModal()">Week overview</button>
|
||||
</div>
|
||||
|
||||
<table class="student-properties-table">
|
||||
|
@ -116,6 +152,49 @@ async function deleteThisStudent() {
|
|||
</p>
|
||||
<p>This <strong>cannot</strong> be undone!</p>
|
||||
</ConfirmDialog>
|
||||
|
||||
<dialog ref="weekOverviewDialog" method="dialog" class="weekly-overview-dialog">
|
||||
<div>
|
||||
<h2>This week's overview for <span v-text="student.name"></span></h2>
|
||||
<div v-for="entry in lastWeeksEntries" :key="entry.id" class="weekly-overview-dialog-day">
|
||||
<h4>{{ getFormattedDate(entry) }}</h4>
|
||||
|
||||
<div v-if="entry.classroomReadiness !== null">
|
||||
<h5>Classroom Readiness</h5>
|
||||
<p v-if="entry.classroomReadiness">✅ Ready for class</p>
|
||||
<p v-if="!entry.classroomReadiness">❌ Not ready for class</p>
|
||||
</div>
|
||||
|
||||
<div v-if="entry.behaviorRating !== null">
|
||||
<h5>Behavior</h5>
|
||||
<p v-if="entry.behaviorRating === 1">
|
||||
Poor
|
||||
</p>
|
||||
<p v-if="entry.behaviorRating === 2">
|
||||
Mediocre
|
||||
</p>
|
||||
<p v-if="entry.behaviorRating === 3">
|
||||
Good
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="entry.comment.length > 0">
|
||||
<h5>Instructor Remarks</h5>
|
||||
<p>{{ entry.comment }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="entry.checklistItems.length > 0">
|
||||
<h5>Classroom Readiness & Behavioral Issues</h5>
|
||||
<ul>
|
||||
<li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-bar align-right">
|
||||
<button @click.prevent="weekOverviewDialog?.close()">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
@ -133,4 +212,27 @@ async function deleteThisStudent() {
|
|||
text-align: right;
|
||||
font-family: 'SourceCodePro', monospace;
|
||||
}
|
||||
|
||||
.weekly-overview-dialog-day {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.weekly-overview-dialog h4 {
|
||||
margin: 1rem 0 -0.5rem -1rem;
|
||||
}
|
||||
|
||||
.weekly-overview-dialog h5 {
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.weekly-overview-dialog p {
|
||||
margin: 0.5rem 0 0.5rem 1rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.weekly-overview-dialog ul {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,6 +3,7 @@ import { computed } from 'vue';
|
|||
|
||||
const props = defineProps<{
|
||||
dateStr: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
@ -27,7 +28,7 @@ function getWeekday(date: Date): string {
|
|||
<template>
|
||||
<th>
|
||||
<div class="date-header-container">
|
||||
<div class="date-header-button">
|
||||
<div class="date-header-button" v-if="!disabled">
|
||||
<span title="Add All Entries" @click="$emit('addAllEntriesClicked')">+</span>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
@ -1,10 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, getDefaultEntry, type Entry } from '@/api/classroom_compliance'
|
||||
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, getDefaultEntry, type Entry } from '@/api/classroom_compliance'
|
||||
import { computed, onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'
|
||||
|
||||
const COMMENT_CHECKLIST_ITEMS = {
|
||||
"Classroom Readiness": [
|
||||
"Tardy",
|
||||
"Out of Uniform",
|
||||
"Use of wireless tech",
|
||||
"10+ minute bathroom pass",
|
||||
"Not having laptop"
|
||||
],
|
||||
"Behavior": [
|
||||
"Talking out of turn",
|
||||
"Throwing objects in class",
|
||||
"Rude language / comments to peers",
|
||||
"Disrespectful towards the teacher"
|
||||
]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
dateStr: string
|
||||
lastSaveStateTimestamp: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
defineEmits<{
|
||||
(e: 'editComment'): void
|
||||
|
@ -17,7 +34,7 @@ const initialEntryJson: Ref<string> = ref('')
|
|||
const previouslyRemovedEntry: Ref<Entry | null> = ref(null)
|
||||
|
||||
const entryChanged = computed(() => JSON.stringify(model.value) !== initialEntryJson.value)
|
||||
const hasComment = computed(() => model.value && model.value.comment.trim().length > 0)
|
||||
const hasComment = computed(() => model.value && (model.value.comment.trim().length > 0 || model.value.checklistItems.length > 0))
|
||||
|
||||
const previousCommentValue: Ref<string> = ref('')
|
||||
const commentEditorDialog = useTemplateRef('commentEditorDialog')
|
||||
|
@ -34,21 +51,24 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
function toggleAbsence() {
|
||||
if (model.value) {
|
||||
if (model.value && !props.disabled) {
|
||||
model.value.absent = !model.value.absent
|
||||
if (model.value.absent) {
|
||||
// Remove additional data if student is absent.
|
||||
model.value.phoneCompliant = null
|
||||
model.value.classroomReadiness = null
|
||||
model.value.behaviorRating = null
|
||||
} else {
|
||||
// Populate default additional data if student is no longer absent.
|
||||
model.value.phoneCompliant = true
|
||||
model.value.classroomReadiness = true
|
||||
model.value.behaviorRating = 3
|
||||
// If we have an initial entry known, restore data from that.
|
||||
if (initialEntryJson.value) {
|
||||
const initialEntry = JSON.parse(initialEntryJson.value) as Entry
|
||||
if (initialEntry === null) return
|
||||
if (initialEntry.absent) return
|
||||
if (initialEntry.classroomReadiness) {
|
||||
model.value.classroomReadiness = initialEntry.classroomReadiness
|
||||
}
|
||||
if (initialEntry.phoneCompliant) {
|
||||
model.value.phoneCompliant = initialEntry.phoneCompliant
|
||||
}
|
||||
|
@ -61,13 +81,19 @@ function toggleAbsence() {
|
|||
}
|
||||
|
||||
function togglePhoneCompliance() {
|
||||
if (model.value && model.value.phoneCompliant !== null) {
|
||||
if (model.value && model.value.phoneCompliant !== null && !props.disabled) {
|
||||
model.value.phoneCompliant = !model.value.phoneCompliant
|
||||
}
|
||||
}
|
||||
|
||||
function toggleClassroomReadiness() {
|
||||
if (model.value && model.value.classroomReadiness !== null && !props.disabled) {
|
||||
model.value.classroomReadiness = !model.value.classroomReadiness
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBehaviorRating() {
|
||||
if (model.value && model.value.behaviorRating) {
|
||||
if (model.value && model.value.behaviorRating && !props.disabled) {
|
||||
model.value.behaviorRating = model.value.behaviorRating - 1
|
||||
if (model.value.behaviorRating < 1) {
|
||||
model.value.behaviorRating = 3
|
||||
|
@ -89,6 +115,7 @@ function cancelCommentEdit() {
|
|||
}
|
||||
|
||||
function removeEntry() {
|
||||
if (props.disabled) return
|
||||
if (model.value) {
|
||||
previouslyRemovedEntry.value = JSON.parse(JSON.stringify(model.value))
|
||||
}
|
||||
|
@ -96,6 +123,7 @@ function removeEntry() {
|
|||
}
|
||||
|
||||
function addEntry() {
|
||||
if (props.disabled) return
|
||||
if (previouslyRemovedEntry.value) {
|
||||
model.value = JSON.parse(JSON.stringify(previouslyRemovedEntry.value))
|
||||
} else {
|
||||
|
@ -107,15 +135,22 @@ function addEntry() {
|
|||
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
|
||||
<div v-if="model" class="cell-container">
|
||||
<div>
|
||||
<div class="status-item" @click="toggleAbsence">
|
||||
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleAbsence">
|
||||
<span v-if="model.absent" title="Absent">{{ EMOJI_ABSENT }}</span>
|
||||
<span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span>
|
||||
</div>
|
||||
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
|
||||
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="togglePhoneCompliance"
|
||||
v-if="!model.absent && model.phoneCompliant !== null">
|
||||
<span v-if="model.phoneCompliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span>
|
||||
<span v-if="!model.phoneCompliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
|
||||
</div>
|
||||
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent">
|
||||
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleClassroomReadiness"
|
||||
v-if="!model.absent && model.classroomReadiness !== null">
|
||||
<span v-if="model.classroomReadiness" title="Ready for Class">{{ EMOJI_CLASSROOM_READY }}</span>
|
||||
<span v-if="!model.classroomReadiness" title="Not Ready for Class">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
|
||||
</div>
|
||||
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleBehaviorRating"
|
||||
v-if="!model.absent">
|
||||
<span v-if="model.behaviorRating === 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.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
||||
|
@ -142,10 +177,20 @@ function addEntry() {
|
|||
<!-- A comment editor dialog that shows up when the user edits their comment. -->
|
||||
<dialog ref="commentEditorDialog" v-if="model">
|
||||
<textarea v-model="model.comment" style="min-width: 300px; min-height: 100px;"
|
||||
@keydown.enter="commentEditorDialog?.close()"></textarea>
|
||||
@keydown.enter="commentEditorDialog?.close()" :readonly="disabled"></textarea>
|
||||
<div>
|
||||
<div v-for="options, category in COMMENT_CHECKLIST_ITEMS" :key="category">
|
||||
<h3 v-text="category"></h3>
|
||||
<label v-for="opt in options" :key="opt">
|
||||
<input type="checkbox" v-model="model.checklistItems" :value="opt" />
|
||||
<span v-text="opt"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-bar" style="text-align: right;">
|
||||
<button type="button" @click="commentEditorDialog?.close()">Confirm</button>
|
||||
<button type="button" @click="model.comment = ''; commentEditorDialog?.close()">Clear Comment</button>
|
||||
<button type="button" @click="commentEditorDialog?.close()" :disabled="disabled">Confirm</button>
|
||||
<button type="button" @click="model.comment = ''; model.checklistItems = []; commentEditorDialog?.close()"
|
||||
:disabled="disabled">Clear</button>
|
||||
<button type="button" @click="cancelCommentEdit">Cancel</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
@ -175,6 +220,10 @@ td {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-item-disabled {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.status-item+.status-item {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth';
|
|||
import { onMounted, onUnmounted, ref, useTemplateRef, type Ref } from 'vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000
|
||||
const REFRESH_INTERVAL_MS = 30000
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const client = new AnnouncementAPIClient(authStore)
|
||||
|
|
Loading…
Reference in New Issue