@studious-lms/server 1.21.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/models/agenda.d.ts +6 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +27 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +4 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +14 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/event.d.ts +11 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +19 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +4 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/gradebook.d.ts +180 -0
- package/dist/models/gradebook.d.ts.map +1 -0
- package/dist/models/gradebook.js +88 -0
- package/dist/models/gradebook.js.map +1 -0
- package/dist/models/labChat.d.ts +4 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +2 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +2 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/section.d.ts +3 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +6 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/routers/_app.d.ts +1241 -67
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +164 -0
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/admin.js +23 -4
- package/dist/routers/admin.js.map +1 -1
- package/dist/routers/agenda.d.ts +6 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +30 -3
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/class.d.ts +17 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/event.d.ts +4 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +66 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/file.js +111 -2
- package/dist/routers/file.js.map +1 -1
- package/dist/routers/gradebook.d.ts +299 -0
- package/dist/routers/gradebook.d.ts.map +1 -0
- package/dist/routers/gradebook.js +107 -0
- package/dist/routers/gradebook.js.map +1 -0
- package/dist/routers/school.d.ts +4 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/section.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +3 -1
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/services/adminInstitution.d.ts +51 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/adminInstitution.js +52 -2
- package/dist/services/adminInstitution.js.map +1 -1
- package/dist/services/adminMetrics.d.ts +17 -0
- package/dist/services/adminMetrics.d.ts.map +1 -0
- package/dist/services/adminMetrics.js +37 -0
- package/dist/services/adminMetrics.js.map +1 -0
- package/dist/services/adminRunJob.d.ts +9 -0
- package/dist/services/adminRunJob.d.ts.map +1 -1
- package/dist/services/adminRunJob.js +18 -2
- package/dist/services/adminRunJob.js.map +1 -1
- package/dist/services/adminUser.d.ts +67 -0
- package/dist/services/adminUser.d.ts.map +1 -0
- package/dist/services/adminUser.js +87 -0
- package/dist/services/adminUser.js.map +1 -0
- package/dist/services/agenda.d.ts +6 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +27 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/class.d.ts +17 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/event.d.ts +4 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/gradeCalculation.d.ts +70 -0
- package/dist/services/gradeCalculation.d.ts.map +1 -0
- package/dist/services/gradeCalculation.js +148 -0
- package/dist/services/gradeCalculation.js.map +1 -0
- package/dist/services/gradebook.d.ts +197 -0
- package/dist/services/gradebook.d.ts.map +1 -0
- package/dist/services/gradebook.js +263 -0
- package/dist/services/gradebook.js.map +1 -0
- package/dist/services/school.d.ts +4 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/worksheet.d.ts +2 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260708011629_add_gradebook_categories_columns_policies/migration.sql +78 -0
- package/prisma/migrations/20260723142314_make_file_annotation_submission_optional/migration.sql +2 -0
- package/prisma/schema.prisma +62 -2
- package/src/models/gradebook.ts +91 -0
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +28 -0
- package/src/routers/file.ts +128 -0
- package/src/routers/gradebook.ts +173 -0
- package/src/services/adminInstitution.ts +52 -0
- package/src/services/adminMetrics.ts +46 -0
- package/src/services/adminRunJob.ts +24 -0
- package/src/services/adminUser.ts +93 -0
- package/src/services/gradeCalculation.ts +253 -0
- package/src/services/gradebook.ts +383 -0
- package/tests/unit/services/gradeCalculation.test.ts +206 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grade calculation – pure functions for computing final class grades from
|
|
3
|
+
* categories, weights, and grade-calculation policies. No I/O; unit-testable.
|
|
4
|
+
*
|
|
5
|
+
* Future policies (standards-based grading, etc.) should extend
|
|
6
|
+
* `GradeCalculationPolicies` and be handled inside `computeFinalGrade` so
|
|
7
|
+
* callers never need to change.
|
|
8
|
+
*/
|
|
9
|
+
/** Synthetic category used for scores without a category. */
|
|
10
|
+
|
|
11
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f05b8c2d-15ff-58b1-b01e-d40dba432326")}catch(e){}}();
|
|
12
|
+
export const DEFAULT_CATEGORY = {
|
|
13
|
+
id: null,
|
|
14
|
+
weight: 1,
|
|
15
|
+
dropLowest: 0,
|
|
16
|
+
};
|
|
17
|
+
export function roundPercentage(value, mode) {
|
|
18
|
+
switch (mode) {
|
|
19
|
+
case "HALF_UP":
|
|
20
|
+
return Math.round(value);
|
|
21
|
+
case "FLOOR":
|
|
22
|
+
return Math.floor(value);
|
|
23
|
+
case "CEILING":
|
|
24
|
+
return Math.ceil(value);
|
|
25
|
+
case "NONE":
|
|
26
|
+
default:
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function collectCountableScores(scores, policies) {
|
|
31
|
+
const countable = [];
|
|
32
|
+
for (const score of scores) {
|
|
33
|
+
if (score.maxPoints <= 0)
|
|
34
|
+
continue;
|
|
35
|
+
if (score.pointsEarned === null) {
|
|
36
|
+
if (!policies.treatMissingAsZero)
|
|
37
|
+
continue;
|
|
38
|
+
countable.push({ percentage: 0, weight: score.weight ?? 1 });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
countable.push({
|
|
42
|
+
percentage: (score.pointsEarned / score.maxPoints) * 100,
|
|
43
|
+
weight: score.weight ?? 1,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return countable;
|
|
47
|
+
}
|
|
48
|
+
function averageCategory(countable, dropLowest) {
|
|
49
|
+
// Always keep at least one score so a category never collapses to null
|
|
50
|
+
// just because dropLowest >= score count.
|
|
51
|
+
const dropCount = Math.min(Math.max(dropLowest, 0), countable.length - 1);
|
|
52
|
+
const kept = dropCount > 0
|
|
53
|
+
? [...countable]
|
|
54
|
+
.sort((a, b) => a.percentage - b.percentage)
|
|
55
|
+
.slice(dropCount)
|
|
56
|
+
: countable;
|
|
57
|
+
const totalWeight = kept.reduce((sum, s) => sum + s.weight, 0);
|
|
58
|
+
if (kept.length === 0 || totalWeight <= 0) {
|
|
59
|
+
return { average: null, counted: 0, dropped: 0 };
|
|
60
|
+
}
|
|
61
|
+
const weighted = kept.reduce((sum, s) => sum + s.percentage * s.weight, 0);
|
|
62
|
+
return {
|
|
63
|
+
average: weighted / totalWeight,
|
|
64
|
+
counted: kept.length,
|
|
65
|
+
dropped: dropCount,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Computes the final class grade as the weighted average of category
|
|
70
|
+
* averages. Scores with no category fall into a default category with
|
|
71
|
+
* weight `DEFAULT_CATEGORY.weight`. Categories without counted scores are
|
|
72
|
+
* excluded from the weighted average.
|
|
73
|
+
*/
|
|
74
|
+
export function computeFinalGrade(policies, categories, scores, options) {
|
|
75
|
+
const knownIds = new Set(categories.map((c) => c.id));
|
|
76
|
+
const byCategory = new Map();
|
|
77
|
+
for (const score of scores) {
|
|
78
|
+
const key = score.categoryId !== null && knownIds.has(score.categoryId)
|
|
79
|
+
? score.categoryId
|
|
80
|
+
: null;
|
|
81
|
+
const bucket = byCategory.get(key);
|
|
82
|
+
if (bucket) {
|
|
83
|
+
bucket.push(score);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
byCategory.set(key, [score]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const categoryAverages = [];
|
|
90
|
+
for (const category of categories) {
|
|
91
|
+
const countable = collectCountableScores(byCategory.get(category.id) ?? [], policies);
|
|
92
|
+
const { average, counted, dropped } = averageCategory(countable, category.dropLowest);
|
|
93
|
+
categoryAverages.push({
|
|
94
|
+
categoryId: category.id,
|
|
95
|
+
name: category.name,
|
|
96
|
+
weight: category.weight,
|
|
97
|
+
average,
|
|
98
|
+
countedScores: counted,
|
|
99
|
+
droppedScores: dropped,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const uncategorized = byCategory.get(null) ?? [];
|
|
103
|
+
if (uncategorized.length > 0) {
|
|
104
|
+
const countable = collectCountableScores(uncategorized, policies);
|
|
105
|
+
const { average, counted, dropped } = averageCategory(countable, DEFAULT_CATEGORY.dropLowest);
|
|
106
|
+
categoryAverages.push({
|
|
107
|
+
categoryId: DEFAULT_CATEGORY.id,
|
|
108
|
+
name: options?.defaultCategoryName ?? "Uncategorized",
|
|
109
|
+
weight: DEFAULT_CATEGORY.weight,
|
|
110
|
+
average,
|
|
111
|
+
countedScores: counted,
|
|
112
|
+
droppedScores: dropped,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const contributing = categoryAverages.filter((c) => c.average !== null && c.weight > 0);
|
|
116
|
+
const totalWeight = contributing.reduce((sum, c) => sum + c.weight, 0);
|
|
117
|
+
const percentage = totalWeight > 0
|
|
118
|
+
? roundPercentage(contributing.reduce((sum, c) => sum + c.average * c.weight, 0) / totalWeight, policies.roundingMode)
|
|
119
|
+
: null;
|
|
120
|
+
return { percentage, categories: categoryAverages };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Maps a percentage to a letter grade using a GradingBoundary `structured`
|
|
124
|
+
* JSON payload (`{ boundaries: [{ grade, minPercentage, maxPercentage }] }`).
|
|
125
|
+
* Returns null when the payload is unparseable or nothing matches.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveLetterGrade(structured, percentage) {
|
|
128
|
+
let boundaries;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(structured);
|
|
131
|
+
if (!Array.isArray(parsed.boundaries))
|
|
132
|
+
return null;
|
|
133
|
+
boundaries = parsed.boundaries;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const sorted = [...boundaries].sort((a, b) => b.minPercentage - a.minPercentage);
|
|
139
|
+
for (const boundary of sorted) {
|
|
140
|
+
if (typeof boundary.minPercentage === "number" &&
|
|
141
|
+
percentage >= boundary.minPercentage) {
|
|
142
|
+
return boundary.grade ?? null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=gradeCalculation.js.map
|
|
148
|
+
//# debugId=f05b8c2d-15ff-58b1-b01e-d40dba432326
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gradeCalculation.js","sources":["services/gradeCalculation.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Grade calculation – pure functions for computing final class grades from\n * categories, weights, and grade-calculation policies. No I/O; unit-testable.\n *\n * Future policies (standards-based grading, etc.) should extend\n * `GradeCalculationPolicies` and be handled inside `computeFinalGrade` so\n * callers never need to change.\n */\n\nexport type GradeRoundingMode = \"NONE\" | \"HALF_UP\" | \"FLOOR\" | \"CEILING\";\n\nexport interface GradeCalculationPolicies {\n /** Count ungraded/missing scores as 0% instead of excluding them. */\n treatMissingAsZero: boolean;\n /** How the final percentage is rounded to a whole number. */\n roundingMode: GradeRoundingMode;\n}\n\nexport interface GradeCategoryInput {\n id: string;\n name: string;\n /** Relative weight of this category toward the final grade. */\n weight: number;\n /** Number of lowest-percentage scores dropped within this category. */\n dropLowest: number;\n}\n\nexport interface ScoreInput {\n /** Category the score belongs to; null falls into the default category. */\n categoryId: string | null;\n /** Points earned; null means missing/ungraded. */\n pointsEarned: number | null;\n /** Maximum points for the item; items with maxPoints <= 0 are ignored. */\n maxPoints: number;\n /** Relative weight of the item within its category (defaults to 1). */\n weight?: number;\n}\n\nexport interface CategoryAverage {\n categoryId: string | null;\n name: string;\n weight: number;\n /** Percentage 0–100, or null when no scores counted. */\n average: number | null;\n countedScores: number;\n droppedScores: number;\n}\n\nexport interface FinalGradeResult {\n /** Final percentage 0–100 (rounded per policy), or null when no data. */\n percentage: number | null;\n categories: CategoryAverage[];\n}\n\n/** Synthetic category used for scores without a category. */\nexport const DEFAULT_CATEGORY = {\n id: null,\n weight: 1,\n dropLowest: 0,\n} as const;\n\nexport function roundPercentage(\n value: number,\n mode: GradeRoundingMode\n): number {\n switch (mode) {\n case \"HALF_UP\":\n return Math.round(value);\n case \"FLOOR\":\n return Math.floor(value);\n case \"CEILING\":\n return Math.ceil(value);\n case \"NONE\":\n default:\n return value;\n }\n}\n\ninterface CountableScore {\n percentage: number;\n weight: number;\n}\n\nfunction collectCountableScores(\n scores: ScoreInput[],\n policies: GradeCalculationPolicies\n): CountableScore[] {\n const countable: CountableScore[] = [];\n for (const score of scores) {\n if (score.maxPoints <= 0) continue;\n if (score.pointsEarned === null) {\n if (!policies.treatMissingAsZero) continue;\n countable.push({ percentage: 0, weight: score.weight ?? 1 });\n continue;\n }\n countable.push({\n percentage: (score.pointsEarned / score.maxPoints) * 100,\n weight: score.weight ?? 1,\n });\n }\n return countable;\n}\n\nfunction averageCategory(\n countable: CountableScore[],\n dropLowest: number\n): { average: number | null; counted: number; dropped: number } {\n // Always keep at least one score so a category never collapses to null\n // just because dropLowest >= score count.\n const dropCount = Math.min(Math.max(dropLowest, 0), countable.length - 1);\n const kept =\n dropCount > 0\n ? [...countable]\n .sort((a, b) => a.percentage - b.percentage)\n .slice(dropCount)\n : countable;\n\n const totalWeight = kept.reduce((sum, s) => sum + s.weight, 0);\n if (kept.length === 0 || totalWeight <= 0) {\n return { average: null, counted: 0, dropped: 0 };\n }\n\n const weighted = kept.reduce((sum, s) => sum + s.percentage * s.weight, 0);\n return {\n average: weighted / totalWeight,\n counted: kept.length,\n dropped: dropCount,\n };\n}\n\n/**\n * Computes the final class grade as the weighted average of category\n * averages. Scores with no category fall into a default category with\n * weight `DEFAULT_CATEGORY.weight`. Categories without counted scores are\n * excluded from the weighted average.\n */\nexport function computeFinalGrade(\n policies: GradeCalculationPolicies,\n categories: GradeCategoryInput[],\n scores: ScoreInput[],\n options?: { defaultCategoryName?: string }\n): FinalGradeResult {\n const knownIds = new Set(categories.map((c) => c.id));\n const byCategory = new Map<string | null, ScoreInput[]>();\n for (const score of scores) {\n const key =\n score.categoryId !== null && knownIds.has(score.categoryId)\n ? score.categoryId\n : null;\n const bucket = byCategory.get(key);\n if (bucket) {\n bucket.push(score);\n } else {\n byCategory.set(key, [score]);\n }\n }\n\n const categoryAverages: CategoryAverage[] = [];\n\n for (const category of categories) {\n const countable = collectCountableScores(\n byCategory.get(category.id) ?? [],\n policies\n );\n const { average, counted, dropped } = averageCategory(\n countable,\n category.dropLowest\n );\n categoryAverages.push({\n categoryId: category.id,\n name: category.name,\n weight: category.weight,\n average,\n countedScores: counted,\n droppedScores: dropped,\n });\n }\n\n const uncategorized = byCategory.get(null) ?? [];\n if (uncategorized.length > 0) {\n const countable = collectCountableScores(uncategorized, policies);\n const { average, counted, dropped } = averageCategory(\n countable,\n DEFAULT_CATEGORY.dropLowest\n );\n categoryAverages.push({\n categoryId: DEFAULT_CATEGORY.id,\n name: options?.defaultCategoryName ?? \"Uncategorized\",\n weight: DEFAULT_CATEGORY.weight,\n average,\n countedScores: counted,\n droppedScores: dropped,\n });\n }\n\n const contributing = categoryAverages.filter(\n (c) => c.average !== null && c.weight > 0\n );\n const totalWeight = contributing.reduce((sum, c) => sum + c.weight, 0);\n\n const percentage =\n totalWeight > 0\n ? roundPercentage(\n contributing.reduce(\n (sum, c) => sum + (c.average as number) * c.weight,\n 0\n ) / totalWeight,\n policies.roundingMode\n )\n : null;\n\n return { percentage, categories: categoryAverages };\n}\n\ninterface ParsedBoundary {\n grade: string;\n minPercentage: number;\n maxPercentage: number;\n}\n\n/**\n * Maps a percentage to a letter grade using a GradingBoundary `structured`\n * JSON payload (`{ boundaries: [{ grade, minPercentage, maxPercentage }] }`).\n * Returns null when the payload is unparseable or nothing matches.\n */\nexport function resolveLetterGrade(\n structured: string,\n percentage: number\n): string | null {\n let boundaries: ParsedBoundary[];\n try {\n const parsed = JSON.parse(structured) as {\n boundaries?: ParsedBoundary[];\n };\n if (!Array.isArray(parsed.boundaries)) return null;\n boundaries = parsed.boundaries;\n } catch {\n return null;\n }\n\n const sorted = [...boundaries].sort(\n (a, b) => b.minPercentage - a.minPercentage\n );\n for (const boundary of sorted) {\n if (\n typeof boundary.minPercentage === \"number\" &&\n percentage >= boundary.minPercentage\n ) {\n return boundary.grade ?? null;\n }\n }\n return null;\n}\n"],"names":[],"mappings":"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","debug_id":"f05b8c2d-15ff-58b1-b01e-d40dba432326"}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gradebook service – grade categories, custom columns/entries, class
|
|
3
|
+
* grade-calculation policies, the computed gradebook, and CSV export.
|
|
4
|
+
*
|
|
5
|
+
* All grade math lives in `gradeCalculation.ts` (pure, unit-tested); this
|
|
6
|
+
* service only assembles inputs and persists configuration.
|
|
7
|
+
*/
|
|
8
|
+
import { GradeRoundingMode as PrismaGradeRoundingMode } from "@prisma/client";
|
|
9
|
+
import { type FinalGradeResult } from "./gradeCalculation.js";
|
|
10
|
+
export declare function listGradeCategories(classId: string): Promise<{
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
classId: string;
|
|
14
|
+
weight: number;
|
|
15
|
+
dropLowest: number;
|
|
16
|
+
position: number;
|
|
17
|
+
}[]>;
|
|
18
|
+
export declare function createGradeCategory(classId: string, data: {
|
|
19
|
+
name: string;
|
|
20
|
+
weight: number;
|
|
21
|
+
dropLowest?: number;
|
|
22
|
+
position?: number;
|
|
23
|
+
}): Promise<{
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
classId: string;
|
|
27
|
+
weight: number;
|
|
28
|
+
dropLowest: number;
|
|
29
|
+
position: number;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function updateGradeCategory(categoryId: string, classId: string, data: {
|
|
32
|
+
name?: string;
|
|
33
|
+
weight?: number;
|
|
34
|
+
dropLowest?: number;
|
|
35
|
+
position?: number;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
classId: string;
|
|
40
|
+
weight: number;
|
|
41
|
+
dropLowest: number;
|
|
42
|
+
position: number;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function deleteGradeCategory(categoryId: string, classId: string): Promise<{
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
classId: string;
|
|
48
|
+
weight: number;
|
|
49
|
+
dropLowest: number;
|
|
50
|
+
position: number;
|
|
51
|
+
}>;
|
|
52
|
+
export declare function assignAssignmentCategory(classId: string, assignmentId: string, gradeCategoryId: string | null): Promise<{
|
|
53
|
+
type: import(".prisma/client").$Enums.AssignmentType;
|
|
54
|
+
id: string;
|
|
55
|
+
createdAt: Date | null;
|
|
56
|
+
classId: string;
|
|
57
|
+
title: string;
|
|
58
|
+
dueDate: Date;
|
|
59
|
+
maxGrade: number | null;
|
|
60
|
+
eventId: string | null;
|
|
61
|
+
instructions: string;
|
|
62
|
+
modifiedAt: Date | null;
|
|
63
|
+
teacherId: string;
|
|
64
|
+
acceptFiles: boolean;
|
|
65
|
+
acceptExtendedResponse: boolean;
|
|
66
|
+
acceptWorksheet: boolean;
|
|
67
|
+
gradeWithAI: boolean;
|
|
68
|
+
aiPolicyLevel: number;
|
|
69
|
+
sectionId: string | null;
|
|
70
|
+
graded: boolean;
|
|
71
|
+
weight: number;
|
|
72
|
+
inProgress: boolean;
|
|
73
|
+
template: boolean;
|
|
74
|
+
markSchemeId: string | null;
|
|
75
|
+
order: number | null;
|
|
76
|
+
gradingBoundaryId: string | null;
|
|
77
|
+
gradeCategoryId: string | null;
|
|
78
|
+
}>;
|
|
79
|
+
export declare function listCustomGradeColumns(classId: string): Promise<({
|
|
80
|
+
entries: {
|
|
81
|
+
id: string;
|
|
82
|
+
points: number;
|
|
83
|
+
studentId: string;
|
|
84
|
+
note: string | null;
|
|
85
|
+
}[];
|
|
86
|
+
} & {
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
classId: string;
|
|
90
|
+
gradeCategoryId: string | null;
|
|
91
|
+
position: number;
|
|
92
|
+
maxPoints: number;
|
|
93
|
+
})[]>;
|
|
94
|
+
export declare function createCustomGradeColumn(classId: string, data: {
|
|
95
|
+
name: string;
|
|
96
|
+
maxPoints: number;
|
|
97
|
+
gradeCategoryId?: string | null;
|
|
98
|
+
position?: number;
|
|
99
|
+
}): Promise<{
|
|
100
|
+
id: string;
|
|
101
|
+
name: string;
|
|
102
|
+
classId: string;
|
|
103
|
+
gradeCategoryId: string | null;
|
|
104
|
+
position: number;
|
|
105
|
+
maxPoints: number;
|
|
106
|
+
}>;
|
|
107
|
+
export declare function updateCustomGradeColumn(columnId: string, classId: string, data: {
|
|
108
|
+
name?: string;
|
|
109
|
+
maxPoints?: number;
|
|
110
|
+
gradeCategoryId?: string | null;
|
|
111
|
+
position?: number;
|
|
112
|
+
}): Promise<{
|
|
113
|
+
id: string;
|
|
114
|
+
name: string;
|
|
115
|
+
classId: string;
|
|
116
|
+
gradeCategoryId: string | null;
|
|
117
|
+
position: number;
|
|
118
|
+
maxPoints: number;
|
|
119
|
+
}>;
|
|
120
|
+
export declare function deleteCustomGradeColumn(columnId: string, classId: string): Promise<{
|
|
121
|
+
id: string;
|
|
122
|
+
name: string;
|
|
123
|
+
classId: string;
|
|
124
|
+
gradeCategoryId: string | null;
|
|
125
|
+
position: number;
|
|
126
|
+
maxPoints: number;
|
|
127
|
+
}>;
|
|
128
|
+
export declare function setCustomGradeEntry(classId: string, columnId: string, studentId: string, points: number | null, note?: string | null): Promise<{
|
|
129
|
+
id: string;
|
|
130
|
+
points: number;
|
|
131
|
+
studentId: string;
|
|
132
|
+
columnId: string;
|
|
133
|
+
note: string | null;
|
|
134
|
+
} | null>;
|
|
135
|
+
export declare function getGradePolicies(classId: string): Promise<{
|
|
136
|
+
id: string;
|
|
137
|
+
treatMissingAsZero: boolean;
|
|
138
|
+
gradeRoundingMode: import(".prisma/client").$Enums.GradeRoundingMode;
|
|
139
|
+
}>;
|
|
140
|
+
export declare function updateGradePolicies(classId: string, data: {
|
|
141
|
+
treatMissingAsZero?: boolean;
|
|
142
|
+
gradeRoundingMode?: PrismaGradeRoundingMode;
|
|
143
|
+
}): Promise<{
|
|
144
|
+
id: string;
|
|
145
|
+
treatMissingAsZero: boolean;
|
|
146
|
+
gradeRoundingMode: import(".prisma/client").$Enums.GradeRoundingMode;
|
|
147
|
+
}>;
|
|
148
|
+
export interface GradebookStudentRow {
|
|
149
|
+
studentId: string;
|
|
150
|
+
studentName: string;
|
|
151
|
+
assignmentScores: Record<string, number | null>;
|
|
152
|
+
customScores: Record<string, {
|
|
153
|
+
points: number | null;
|
|
154
|
+
note: string | null;
|
|
155
|
+
}>;
|
|
156
|
+
result: FinalGradeResult;
|
|
157
|
+
letterGrade: string | null;
|
|
158
|
+
}
|
|
159
|
+
export declare function getComputedGradebook(classId: string, userId: string): Promise<{
|
|
160
|
+
policies: {
|
|
161
|
+
treatMissingAsZero: boolean;
|
|
162
|
+
roundingMode: import(".prisma/client").$Enums.GradeRoundingMode;
|
|
163
|
+
};
|
|
164
|
+
categories: {
|
|
165
|
+
id: string;
|
|
166
|
+
name: string;
|
|
167
|
+
classId: string;
|
|
168
|
+
weight: number;
|
|
169
|
+
dropLowest: number;
|
|
170
|
+
position: number;
|
|
171
|
+
}[];
|
|
172
|
+
assignments: {
|
|
173
|
+
id: string;
|
|
174
|
+
title: string;
|
|
175
|
+
maxGrade: number | null;
|
|
176
|
+
weight: number;
|
|
177
|
+
gradeCategoryId: string | null;
|
|
178
|
+
dueDate: Date;
|
|
179
|
+
}[];
|
|
180
|
+
customColumns: {
|
|
181
|
+
id: string;
|
|
182
|
+
name: string;
|
|
183
|
+
maxPoints: number;
|
|
184
|
+
gradeCategoryId: string | null;
|
|
185
|
+
position: number;
|
|
186
|
+
}[];
|
|
187
|
+
gradingBoundary: {
|
|
188
|
+
id: string;
|
|
189
|
+
name: string | null;
|
|
190
|
+
structured: string;
|
|
191
|
+
};
|
|
192
|
+
students: GradebookStudentRow[];
|
|
193
|
+
}>;
|
|
194
|
+
export declare function exportGradebookCsv(classId: string, userId: string): Promise<{
|
|
195
|
+
csv: string;
|
|
196
|
+
}>;
|
|
197
|
+
//# sourceMappingURL=gradebook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gradebook.d.ts","sourceRoot":"/","sources":["services/gradebook.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,iBAAiB,IAAI,uBAAuB,EAAE,MAAM,gBAAgB,CAAC;AAY9E,OAAO,EAGL,KAAK,gBAAgB,EAGtB,MAAM,uBAAuB,CAAC;AAI/B,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,MAAM;;;;;;;KAExD;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE;;;;;;;GAW/E;AAED,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IACJ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;;;;;;;GAUF;AAED,wBAAsB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;;;;;;;GAM5E;AAED,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,EACpB,eAAe,EAAE,MAAM,GAAG,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;GAY/B;AAID,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM;;;;;;;;;;;;;;MAE3D;AAED,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IACJ,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;;;;;;;GAkBF;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IACJ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;;;;;;;GAgBF;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM;;;;;;;GAOhB;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;;;;;;UAiBrB;AAID,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,MAAM;;;;GAMrD;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IACJ,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;CAC7C;;;;GAWF;AAID,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;IAChD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;IAC7E,MAAM,EAAE,gBAAgB,CAAC;IACzB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmGzE;AAWD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;;GA8CvE"}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="2860d786-c6b1-5d9a-b1bd-880c5bef0f51")}catch(e){}}();
|
|
3
|
+
import { TRPCError } from "@trpc/server";
|
|
4
|
+
import { prisma } from "../lib/prisma.js";
|
|
5
|
+
import { isTeacherInClass } from "../models/class.js";
|
|
6
|
+
import { findGradeCategoriesByClassId, findGradeCategory, findCustomGradeColumnsByClassId, findCustomGradeColumn, findClassForGradebook, findClassGradePolicies, } from "../models/gradebook.js";
|
|
7
|
+
import { computeFinalGrade, resolveLetterGrade, } from "./gradeCalculation.js";
|
|
8
|
+
// ===== Categories =====
|
|
9
|
+
export async function listGradeCategories(classId) {
|
|
10
|
+
return findGradeCategoriesByClassId(classId);
|
|
11
|
+
}
|
|
12
|
+
export async function createGradeCategory(classId, data) {
|
|
13
|
+
return prisma.gradeCategory.create({
|
|
14
|
+
data: {
|
|
15
|
+
classId,
|
|
16
|
+
name: data.name,
|
|
17
|
+
weight: data.weight,
|
|
18
|
+
dropLowest: data.dropLowest ?? 0,
|
|
19
|
+
position: data.position ?? (await findGradeCategoriesByClassId(classId)).length,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function updateGradeCategory(categoryId, classId, data) {
|
|
24
|
+
const category = await findGradeCategory(categoryId, classId);
|
|
25
|
+
if (!category) {
|
|
26
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
|
|
27
|
+
}
|
|
28
|
+
return prisma.gradeCategory.update({
|
|
29
|
+
where: { id: categoryId },
|
|
30
|
+
data,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function deleteGradeCategory(categoryId, classId) {
|
|
34
|
+
const category = await findGradeCategory(categoryId, classId);
|
|
35
|
+
if (!category) {
|
|
36
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
|
|
37
|
+
}
|
|
38
|
+
return prisma.gradeCategory.delete({ where: { id: categoryId } });
|
|
39
|
+
}
|
|
40
|
+
export async function assignAssignmentCategory(classId, assignmentId, gradeCategoryId) {
|
|
41
|
+
if (gradeCategoryId) {
|
|
42
|
+
const category = await findGradeCategory(gradeCategoryId, classId);
|
|
43
|
+
if (!category) {
|
|
44
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return prisma.assignment.update({
|
|
48
|
+
where: { id: assignmentId, classId },
|
|
49
|
+
data: { gradeCategoryId },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// ===== Custom columns =====
|
|
53
|
+
export async function listCustomGradeColumns(classId) {
|
|
54
|
+
return findCustomGradeColumnsByClassId(classId);
|
|
55
|
+
}
|
|
56
|
+
export async function createCustomGradeColumn(classId, data) {
|
|
57
|
+
if (data.gradeCategoryId) {
|
|
58
|
+
const category = await findGradeCategory(data.gradeCategoryId, classId);
|
|
59
|
+
if (!category) {
|
|
60
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return prisma.customGradeColumn.create({
|
|
64
|
+
data: {
|
|
65
|
+
classId,
|
|
66
|
+
name: data.name,
|
|
67
|
+
maxPoints: data.maxPoints,
|
|
68
|
+
gradeCategoryId: data.gradeCategoryId ?? null,
|
|
69
|
+
position: data.position ?? (await findCustomGradeColumnsByClassId(classId)).length,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export async function updateCustomGradeColumn(columnId, classId, data) {
|
|
74
|
+
const column = await findCustomGradeColumn(columnId, classId);
|
|
75
|
+
if (!column) {
|
|
76
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
|
|
77
|
+
}
|
|
78
|
+
if (data.gradeCategoryId) {
|
|
79
|
+
const category = await findGradeCategory(data.gradeCategoryId, classId);
|
|
80
|
+
if (!category) {
|
|
81
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Category not found" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return prisma.customGradeColumn.update({
|
|
85
|
+
where: { id: columnId },
|
|
86
|
+
data,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export async function deleteCustomGradeColumn(columnId, classId) {
|
|
90
|
+
const column = await findCustomGradeColumn(columnId, classId);
|
|
91
|
+
if (!column) {
|
|
92
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
|
|
93
|
+
}
|
|
94
|
+
return prisma.customGradeColumn.delete({ where: { id: columnId } });
|
|
95
|
+
}
|
|
96
|
+
export async function setCustomGradeEntry(classId, columnId, studentId, points, note) {
|
|
97
|
+
const column = await findCustomGradeColumn(columnId, classId);
|
|
98
|
+
if (!column) {
|
|
99
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Column not found" });
|
|
100
|
+
}
|
|
101
|
+
if (points === null) {
|
|
102
|
+
await prisma.customGradeEntry.deleteMany({
|
|
103
|
+
where: { columnId, studentId },
|
|
104
|
+
});
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return prisma.customGradeEntry.upsert({
|
|
108
|
+
where: { columnId_studentId: { columnId, studentId } },
|
|
109
|
+
create: { columnId, studentId, points, note: note ?? null },
|
|
110
|
+
update: { points, ...(note !== undefined ? { note } : {}) },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// ===== Policies =====
|
|
114
|
+
export async function getGradePolicies(classId) {
|
|
115
|
+
const policies = await findClassGradePolicies(classId);
|
|
116
|
+
if (!policies) {
|
|
117
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
118
|
+
}
|
|
119
|
+
return policies;
|
|
120
|
+
}
|
|
121
|
+
export async function updateGradePolicies(classId, data) {
|
|
122
|
+
return prisma.class.update({
|
|
123
|
+
where: { id: classId },
|
|
124
|
+
data,
|
|
125
|
+
select: {
|
|
126
|
+
id: true,
|
|
127
|
+
treatMissingAsZero: true,
|
|
128
|
+
gradeRoundingMode: true,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
export async function getComputedGradebook(classId, userId) {
|
|
133
|
+
const [classData, categories, columns, isTeacher] = await Promise.all([
|
|
134
|
+
findClassForGradebook(classId),
|
|
135
|
+
findGradeCategoriesByClassId(classId),
|
|
136
|
+
findCustomGradeColumnsByClassId(classId),
|
|
137
|
+
isTeacherInClass(classId, userId),
|
|
138
|
+
]);
|
|
139
|
+
if (!classData) {
|
|
140
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
141
|
+
}
|
|
142
|
+
// Students only ever see their own row.
|
|
143
|
+
const students = isTeacher
|
|
144
|
+
? classData.students
|
|
145
|
+
: classData.students.filter((student) => student.id === userId);
|
|
146
|
+
const policies = {
|
|
147
|
+
treatMissingAsZero: classData.treatMissingAsZero,
|
|
148
|
+
roundingMode: classData.gradeRoundingMode,
|
|
149
|
+
};
|
|
150
|
+
const categoryInputs = categories.map((category) => ({
|
|
151
|
+
id: category.id,
|
|
152
|
+
name: category.name,
|
|
153
|
+
weight: category.weight,
|
|
154
|
+
dropLowest: category.dropLowest,
|
|
155
|
+
}));
|
|
156
|
+
const boundary = classData.gradingBoundaries[0] ?? null;
|
|
157
|
+
const rows = students.map((student) => {
|
|
158
|
+
const assignmentScores = {};
|
|
159
|
+
const scores = [];
|
|
160
|
+
for (const assignment of classData.assignments) {
|
|
161
|
+
const submission = assignment.submissions.find((item) => item.studentId === student.id);
|
|
162
|
+
const pointsEarned = submission?.gradeReceived ?? null;
|
|
163
|
+
assignmentScores[assignment.id] = pointsEarned;
|
|
164
|
+
scores.push({
|
|
165
|
+
categoryId: assignment.gradeCategoryId,
|
|
166
|
+
pointsEarned,
|
|
167
|
+
maxPoints: assignment.maxGrade ?? 0,
|
|
168
|
+
weight: assignment.weight,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const customScores = {};
|
|
172
|
+
for (const column of columns) {
|
|
173
|
+
const entry = column.entries.find((item) => item.studentId === student.id);
|
|
174
|
+
customScores[column.id] = {
|
|
175
|
+
points: entry?.points ?? null,
|
|
176
|
+
note: entry?.note ?? null,
|
|
177
|
+
};
|
|
178
|
+
scores.push({
|
|
179
|
+
categoryId: column.gradeCategoryId,
|
|
180
|
+
pointsEarned: entry?.points ?? null,
|
|
181
|
+
maxPoints: column.maxPoints,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const result = computeFinalGrade(policies, categoryInputs, scores);
|
|
185
|
+
const letterGrade = boundary && result.percentage !== null
|
|
186
|
+
? resolveLetterGrade(boundary.structured, result.percentage)
|
|
187
|
+
: null;
|
|
188
|
+
return {
|
|
189
|
+
studentId: student.id,
|
|
190
|
+
studentName: student.profile?.displayName || student.username,
|
|
191
|
+
assignmentScores,
|
|
192
|
+
customScores,
|
|
193
|
+
result,
|
|
194
|
+
letterGrade,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
policies,
|
|
199
|
+
categories,
|
|
200
|
+
assignments: classData.assignments.map((assignment) => ({
|
|
201
|
+
id: assignment.id,
|
|
202
|
+
title: assignment.title,
|
|
203
|
+
maxGrade: assignment.maxGrade,
|
|
204
|
+
weight: assignment.weight,
|
|
205
|
+
gradeCategoryId: assignment.gradeCategoryId,
|
|
206
|
+
dueDate: assignment.dueDate,
|
|
207
|
+
})),
|
|
208
|
+
customColumns: columns.map((column) => ({
|
|
209
|
+
id: column.id,
|
|
210
|
+
name: column.name,
|
|
211
|
+
maxPoints: column.maxPoints,
|
|
212
|
+
gradeCategoryId: column.gradeCategoryId,
|
|
213
|
+
position: column.position,
|
|
214
|
+
})),
|
|
215
|
+
gradingBoundary: boundary,
|
|
216
|
+
students: rows,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// ===== CSV export =====
|
|
220
|
+
function escapeCsvCell(value) {
|
|
221
|
+
if (/[",\n\r]/.test(value)) {
|
|
222
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
223
|
+
}
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
export async function exportGradebookCsv(classId, userId) {
|
|
227
|
+
const gradebook = await getComputedGradebook(classId, userId);
|
|
228
|
+
const header = [
|
|
229
|
+
"Student",
|
|
230
|
+
...gradebook.assignments.map((assignment) => `${assignment.title} (/${assignment.maxGrade ?? 0})`),
|
|
231
|
+
...gradebook.customColumns.map((column) => `${column.name} (/${column.maxPoints})`),
|
|
232
|
+
...gradebook.categories.map((category) => `${category.name} Avg (%)`),
|
|
233
|
+
"Final (%)",
|
|
234
|
+
"Letter",
|
|
235
|
+
];
|
|
236
|
+
const lines = [header.map(escapeCsvCell).join(",")];
|
|
237
|
+
for (const row of gradebook.students) {
|
|
238
|
+
const categoryCells = gradebook.categories.map((category) => {
|
|
239
|
+
const average = row.result.categories.find((item) => item.categoryId === category.id)?.average;
|
|
240
|
+
return average === null || average === undefined
|
|
241
|
+
? ""
|
|
242
|
+
: average.toFixed(2);
|
|
243
|
+
});
|
|
244
|
+
const cells = [
|
|
245
|
+
row.studentName,
|
|
246
|
+
...gradebook.assignments.map((assignment) => {
|
|
247
|
+
const points = row.assignmentScores[assignment.id];
|
|
248
|
+
return points === null || points === undefined ? "" : String(points);
|
|
249
|
+
}),
|
|
250
|
+
...gradebook.customColumns.map((column) => {
|
|
251
|
+
const points = row.customScores[column.id]?.points;
|
|
252
|
+
return points === null || points === undefined ? "" : String(points);
|
|
253
|
+
}),
|
|
254
|
+
...categoryCells,
|
|
255
|
+
row.result.percentage === null ? "" : row.result.percentage.toFixed(2),
|
|
256
|
+
row.letterGrade ?? "",
|
|
257
|
+
];
|
|
258
|
+
lines.push(cells.map(escapeCsvCell).join(","));
|
|
259
|
+
}
|
|
260
|
+
return { csv: lines.join("\n") };
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=gradebook.js.map
|
|
263
|
+
//# debugId=2860d786-c6b1-5d9a-b1bd-880c5bef0f51
|