feat: cascade deletes, student notes, import popups, module reorganization

- Cascade delete on all entities (student, module, UE, user, role, promotion)
- Fix Response body reuse bug (factory functions instead of constants)
- Student note viewing via CAS uid (strip non-digit prefix)
- Fix middleware page visibility for students in LOCAL mode
- Import result popup component (shared across all import pages)
- Fix student import to use numEtud from Excel
- Bulk student selection with promo change and delete
- Move UE/UE-Module API and pages from notes to admin module
- Move promotions page from students to admin module
- Multi-year maquette import with per-year promo selection
- Inline promo creation in maquette import
- Static Excel templates (students, notes, maquette)
- Fix XLSX export using blob download instead of writeFile
- Allow students to read modules list (GET /modules)
This commit is contained in:
2026-04-30 13:47:16 +02:00
parent 04be659d6b
commit 6c38cd0019
51 changed files with 3022 additions and 437 deletions
+17 -2
View File
@@ -52,8 +52,12 @@ export const handler: Handlers<null, AuthenticatedState> = {
}
try {
const body: { numEtud: number; idUE: number; valeur: number } =
await request.json();
const body: {
numEtud: number;
idUE: number;
valeur: number;
malus?: number;
} = await request.json();
if (!body.numEtud || !body.idUE || body.valeur === undefined) {
return new Response(
@@ -62,12 +66,23 @@ export const handler: Handlers<null, AuthenticatedState> = {
);
}
if (
body.malus !== undefined &&
(!Number.isInteger(body.malus) || body.malus < 0)
) {
return new Response(
JSON.stringify({ error: "malus doit être un entier >= 0" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [created] = await db
.insert(ajustements)
.values({
numEtud: body.numEtud,
idUE: body.idUE,
valeur: body.valeur,
malus: body.malus ?? 0,
})
.returning();
@@ -4,12 +4,13 @@ import { ajustements } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ajustement introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ajustement introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #50 GET /ajustements/{numEtud}/{idUE}
@@ -18,7 +19,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -34,7 +35,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.then((rows) => rows[0] ?? null);
if (!ajustement) return NOT_FOUND;
if (!ajustement) return NOT_FOUND();
return new Response(JSON.stringify(ajustement), {
headers: { "content-type": "application/json" },
@@ -47,7 +48,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -57,7 +58,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
return new Response("Paramètres invalides", { status: 400 });
}
const body: { valeur: number } = await request.json();
const body: { valeur: number; malus?: number } = await request.json();
if (body.valeur === undefined) {
return new Response(JSON.stringify({ error: "Champ requis: valeur" }), {
@@ -66,13 +67,28 @@ export const handler: Handlers<null, AuthenticatedState> = {
});
}
if (
body.malus !== undefined &&
(!Number.isInteger(body.malus) || body.malus < 0)
) {
return new Response(
JSON.stringify({ error: "malus doit être un entier >= 0" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const set: { valeur: number; malus?: number } = { valeur: body.valeur };
if (body.malus !== undefined) {
set.malus = body.malus;
}
const [updated] = await db
.update(ajustements)
.set({ valeur: body.valeur })
.set(set)
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -85,7 +101,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -100,7 +116,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)))
.returning();
if (!deleted) return NOT_FOUND;
if (!deleted) return NOT_FOUND();
return new Response(null, { status: 204 });
},
+27 -2
View File
@@ -41,7 +41,7 @@ export const handler: Handlers = {
async POST(request) {
try {
const body = await request.json();
const { note, numEtud, idModule } = body;
const { note, numEtud, idModule, noteSession2 } = body;
if (note === undefined || !numEtud || !idModule) {
return new Response("Champs 'note', 'numEtud' et 'idModule' requis", {
@@ -55,7 +55,32 @@ export const handler: Handlers = {
});
}
const result = await db.insert(notes).values({ note, numEtud, idModule })
if (
noteSession2 !== undefined && noteSession2 !== null &&
(typeof noteSession2 !== "number" || noteSession2 < 0 ||
noteSession2 > 20)
) {
return new Response(
"Champ 'noteSession2' doit être un nombre entre 0 et 20",
{ status: 400 },
);
}
const values: {
note: number;
numEtud: number;
idModule: string;
noteSession2?: number | null;
} = {
note,
numEtud,
idModule,
};
if (noteSession2 !== undefined) {
values.noteSession2 = noteSession2;
}
const result = await db.insert(notes).values(values)
.returning();
return new Response(JSON.stringify(result[0]), {
@@ -64,13 +64,39 @@ export const handler: Handlers = {
}
const body = await request.json();
const { note } = body;
const { note, noteSession2 } = body;
if (note === undefined) {
return new Response("Champ 'note' manquant", { status: 400 });
if (note === undefined && noteSession2 === undefined) {
return new Response("Au moins 'note' ou 'noteSession2' requis", {
status: 400,
});
}
const result = await db.update(notes).set({ note }).where(
if (
note !== undefined &&
(typeof note !== "number" || note < 0 || note > 20)
) {
return new Response("Champ 'note' doit être un nombre entre 0 et 20", {
status: 400,
});
}
if (
noteSession2 !== undefined && noteSession2 !== null &&
(typeof noteSession2 !== "number" || noteSession2 < 0 ||
noteSession2 > 20)
) {
return new Response(
"Champ 'noteSession2' doit être un nombre entre 0 et 20",
{ status: 400 },
);
}
const set: { note?: number; noteSession2?: number | null } = {};
if (note !== undefined) set.note = note;
if (noteSession2 !== undefined) set.noteSession2 = noteSession2;
const result = await db.update(notes).set(set).where(
and(
eq(notes.numEtud, numEtud),
eq(notes.idModule, idModule),
+21 -3
View File
@@ -26,20 +26,38 @@ export const handler: Handlers = {
const rows = XLSX.utils.sheet_to_json(sheet) as {
numEtud: number;
note: number;
noteSession2?: number;
}[];
for (const row of rows) {
const { numEtud, note } = row;
const { numEtud, note, noteSession2 } = row;
if (!numEtud || note === undefined) {
continue;
}
const values: {
numEtud: number;
idModule: string;
note: number;
noteSession2?: number | null;
} = {
numEtud,
idModule,
note,
};
const set: { note: number; noteSession2?: number | null } = { note };
if (noteSession2 !== undefined) {
values.noteSession2 = noteSession2;
set.noteSession2 = noteSession2;
}
await db.insert(notes)
.values({ numEtud, idModule, note })
.values(values)
.onConflictDoUpdate({
target: [notes.numEtud, notes.idModule],
set: { note },
set,
});
}
-72
View File
@@ -1,72 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "../../../../databases/db.ts";
import { ueModules } from "../../../../databases/schema.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
// #37 GET /ue-modules
async GET(request) {
try {
const url = new URL(request.url);
const idPromo = url.searchParams.get("idPromo");
const idUEParam = url.searchParams.get("idUE");
const idUE = idUEParam ? parseInt(idUEParam) : null;
if (idUEParam && isNaN(idUE!)) {
return new Response("Paramètre idUE invalide", { status: 400 });
}
const result = await db.select().from(ueModules).where(
and(
idPromo ? eq(ueModules.idPromo, idPromo) : undefined,
idUE ? eq(ueModules.idUE, idUE) : undefined,
),
);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching UE-modules:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// #38 POST /ue-modules
async POST(request) {
try {
const body = await request.json();
const { idModule, idUE, idPromo, coeff } = body;
if (!idModule || !idUE || !idPromo || coeff === undefined) {
return new Response(
"Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis",
{ status: 400 },
);
}
if (typeof coeff !== "number" || coeff < 0) {
return new Response("Champ 'coeff' doit être un nombre >= 0", {
status: 400,
});
}
const result = await db.insert(ueModules).values({
idModule,
idUE,
idPromo,
coeff,
}).returning();
return new Response(JSON.stringify(result[0]), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error creating UE-module:", error);
return new Response("Failed to create UE-module", { status: 500 });
}
},
};
@@ -1,139 +0,0 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { ueModules } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Association UE-Module introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const BAD_REQUEST = new Response(
JSON.stringify({ error: "Paramètres invalides" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #39 GET /ue-modules/{idModule}/{idUE}/{idPromo}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
}
const idModule = context.params.idModule;
const idUE = Number(context.params.idUE);
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST;
}
const ueModuleAssociation = await db
.select()
.from(ueModules)
.where(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
)
.then((rows) => rows[0] ?? null);
if (!ueModuleAssociation) return NOT_FOUND;
return new Response(JSON.stringify(ueModuleAssociation), {
headers: { "content-type": "application/json" },
});
},
// #40 PUT /ue-modules/{idModule}/{idUE}/{idPromo}
async PUT(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
}
const idModule = context.params.idModule;
const idUE = Number(context.params.idUE);
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST;
}
const body: { coeff: number } = await request.json();
if (typeof body.coeff !== "number") {
return new Response(
JSON.stringify({ error: "Le champ 'coeff' doit être un nombre" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [updated] = await db
.update(ueModules)
.set({ coeff: body.coeff })
.where(
and(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
),
)
.returning();
if (!updated) return NOT_FOUND;
return new Response(
JSON.stringify({
idModule: updated.idModule,
idUE: updated.idUE,
idPromo: updated.idPromo,
coeff: updated.coeff,
}),
{
headers: { "content-type": "application/json" },
},
);
},
// #41 DELETE /ue-modules/{idModule}/{idUE}/{idPromo}
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
}
const idModule = context.params.idModule;
const idUE = Number(context.params.idUE);
const idPromo = context.params.idPromo;
if (isNaN(idUE)) {
return BAD_REQUEST;
}
const [deleted] = await db
.delete(ueModules)
.where(
and(
eq(ueModules.idModule, idModule),
eq(ueModules.idUE, idUE),
eq(ueModules.idPromo, idPromo),
),
)
.returning();
if (!deleted) return NOT_FOUND;
return new Response(null, { status: 204 });
},
};
-42
View File
@@ -1,42 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "../../../../databases/db.ts";
import { ues } from "../../../../databases/schema.ts";
export const handler: Handlers = {
// #32 GET /ues
async GET() {
try {
const result = await db.select().from(ues);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching UEs:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// #33 POST /ues
async POST(request) {
try {
const body = await request.json();
const { nom } = body;
if (!nom || !nom.trim()) {
return new Response("Champ 'nom' manquant", { status: 400 });
}
const result = await db.insert(ues).values({ nom }).returning();
return new Response(JSON.stringify(result[0]), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error creating UE:", error);
return new Response("Failed to create UE", { status: 500 });
}
},
};
-122
View File
@@ -1,122 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { db } from "../../../../../databases/db.ts";
import { ues } from "../../../../../databases/schema.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
export const handler: Handlers = {
// # 34 GET /ues/:idUE
async GET(_request, context) {
try {
const idUE = parseInt(context.params.idUE);
if (isNaN(idUE)) {
return new Response(
JSON.stringify({ error: "Paramètre idUE invalide" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const result = await db.select().from(ues).where(eq(ues.id, idUE));
if (result.length === 0) {
return new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify(result[0]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching UE:", error);
return new Response("Failed to fetch data", { status: 500 });
}
},
// #35 PUT /ues/:idUE
async PUT(request, context) {
try {
const idUE = parseInt(context.params.idUE);
if (isNaN(idUE)) {
return new Response(
JSON.stringify({ error: "Paramètre idUE invalide" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const body = await request.json();
const { nom } = body;
if (!nom) {
return new Response("Champ 'nom' manquant", { status: 400 });
}
const result = await db.update(ues).set({ nom }).where(eq(ues.id, idUE))
.returning();
if (result.length === 0) {
return new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify(result[0]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error updating UE:", error);
return new Response("Failed to update UE", { status: 500 });
}
},
// #36 DELETE /ues/:idUE
async DELETE(_request, context) {
try {
const idUE = parseInt(context.params.idUE);
if (isNaN(idUE)) {
return new Response(
JSON.stringify({ error: "Paramètre idUE invalide" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const result = await db.delete(ues).where(eq(ues.id, idUE)).returning();
if (result.length === 0) {
return new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(null, { status: 204 });
} catch (error) {
console.error("Error deleting UE:", error);
return new Response("Failed to delete UE", { status: 500 });
}
},
};