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:
@@ -1,246 +0,0 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
type Promotion = { id: string; annee: string | null };
|
||||
type Student = { numEtud: number; idPromo: string };
|
||||
|
||||
function parsePromo(id: string) {
|
||||
const m = id.match(/^(\d+A)(FISE|FISA)(.+)$/);
|
||||
if (!m) return { annee: id, filiere: "?", anneeSco: "?" };
|
||||
return { annee: m[1], filiere: m[2], anneeSco: m[3] };
|
||||
}
|
||||
|
||||
const ANNEES = ["3A", "4A", "5A"];
|
||||
const FILIERES = ["FISE", "FISA"];
|
||||
|
||||
export default function AdminPromotions() {
|
||||
const [promos, setPromos] = useState<Promotion[]>([]);
|
||||
const [students, setStudents] = useState<Student[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// PromoBuilder state
|
||||
const [selectedAnnee, setSelectedAnnee] = useState("4A");
|
||||
const [selectedFiliere, setSelectedFiliere] = useState("FISE");
|
||||
const [anneeSco, setAnneeSco] = useState("");
|
||||
|
||||
const generatedId = anneeSco.trim()
|
||||
? `${selectedAnnee}${selectedFiliere}${anneeSco.trim().replace(/\//g, "-")}`
|
||||
: "";
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [pRes, sRes] = await Promise.all([
|
||||
fetch("/students/api/promotions"),
|
||||
fetch("/students/api/students"),
|
||||
]);
|
||||
if (!pRes.ok) throw new Error("Impossible de charger les promotions");
|
||||
setPromos(await pRes.json());
|
||||
if (sRes.ok) setStudents(await sRes.json());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createPromo() {
|
||||
if (!generatedId) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/students/api/promotions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
idPromo: generatedId,
|
||||
annee: selectedAnnee,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? "Création échouée");
|
||||
}
|
||||
setAnneeSco("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePromo(id: string) {
|
||||
if (!confirm(`Supprimer la promotion ${id} ?`)) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/students/api/promotions/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
function studentCount(idPromo: string) {
|
||||
return students.filter((s) => s.idPromo === idPromo).length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Promotions</h2>
|
||||
|
||||
{error && <p class="state-error">{error}</p>}
|
||||
|
||||
{/* PromoBuilder */}
|
||||
<div class="promo-builder">
|
||||
<p class="promo-builder-title">Créer une promotion</p>
|
||||
<p class="promo-builder-subtitle">
|
||||
idPromo est généré automatiquement
|
||||
</p>
|
||||
|
||||
<div class="promo-builder-row">
|
||||
<div class="promo-builder-field">
|
||||
<label>Année</label>
|
||||
<div class="pill-group">
|
||||
{ANNEES.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
class={`pill-btn${selectedAnnee === a ? " active" : ""}`}
|
||||
onClick={() => setSelectedAnnee(a)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="promo-builder-field">
|
||||
<label>Filière</label>
|
||||
<div class="pill-group">
|
||||
{FILIERES.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
class={`pill-btn${selectedFiliere === f ? " active" : ""}`}
|
||||
onClick={() => setSelectedFiliere(f)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="promo-builder-field">
|
||||
<label>Année scolaire</label>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="ex: 25-26, 24-27…"
|
||||
value={anneeSco}
|
||||
onInput={(e) => setAnneeSco((e.target as HTMLInputElement).value)}
|
||||
style="min-width: 9rem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem">
|
||||
<span style="font-size: 0.78rem; color: light-dark(var(--light-foreground-dim), var(--dark-foreground-dim))">
|
||||
idPromo généré :
|
||||
</span>
|
||||
<span class="promo-id-preview">
|
||||
{generatedId || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={createPromo}
|
||||
disabled={creating || !generatedId}
|
||||
>
|
||||
{creating ? "…" : "+ Créer la promo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing promotions table */}
|
||||
<p style="font-size: 0.82rem; font-weight: var(--font-weight-bold); margin-bottom: 0.5rem">
|
||||
Promotions existantes
|
||||
</p>
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>idPromo</th>
|
||||
<th>Année</th>
|
||||
<th>Filière</th>
|
||||
<th>Année sco.</th>
|
||||
<th>Nb étudiants</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{promos.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={6} class="state-empty">
|
||||
Aucune promotion enregistrée
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: promos.map((p) => {
|
||||
const parsed = parsePromo(p.id);
|
||||
const count = studentCount(p.id);
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td>
|
||||
<span class="promo-chip">{p.id}</span>
|
||||
</td>
|
||||
<td>{parsed.annee}</td>
|
||||
<td>
|
||||
<span class="filiere-chip">{parsed.filiere}</span>
|
||||
</td>
|
||||
<td>{parsed.anneeSco}</td>
|
||||
<td class="col-dim">
|
||||
{count} étudiant{count !== 1 ? "s" : ""}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
onClick={() => deletePromo(p.id)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<rect x="5" y="6" width="14" height="16" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ export default function ConsultStudents() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterPromo, setFilterPromo] = useState("");
|
||||
const [filterNom, setFilterNom] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkPromo, setBulkPromo] = useState("");
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -44,6 +47,11 @@ export default function ConsultStudents() {
|
||||
});
|
||||
if (!res.ok) throw new Error("Suppression échouée");
|
||||
await load();
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(numEtud);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
}
|
||||
@@ -56,6 +64,85 @@ export default function ConsultStudents() {
|
||||
return matchPromo && matchNom;
|
||||
});
|
||||
|
||||
const filteredIds = new Set(filtered.map((s) => s.numEtud));
|
||||
const selectedInView = [...selected].filter((id) => filteredIds.has(id));
|
||||
const allFilteredSelected = filtered.length > 0 &&
|
||||
filtered.every((s) => selected.has(s.numEtud));
|
||||
|
||||
function toggleOne(numEtud: number) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(numEtud)) next.delete(numEtud);
|
||||
else next.add(numEtud);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allFilteredSelected) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const s of filtered) next.delete(s.numEtud);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const s of filtered) next.add(s.numEtud);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
const count = selectedInView.length;
|
||||
if (count === 0) return;
|
||||
if (
|
||||
!confirm(`Supprimer définitivement ${count} élève(s) sélectionné(s) ?`)
|
||||
) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
selectedInView.map((id) =>
|
||||
fetch(`/students/api/students/${id}`, { method: "DELETE" })
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
if (failed > 0) setError(`${failed} suppression(s) échouée(s)`);
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkChangePromo() {
|
||||
if (!bulkPromo || selectedInView.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
selectedInView.map((id) =>
|
||||
fetch(`/students/api/students/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ idPromo: bulkPromo }),
|
||||
})
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
if (failed > 0) setError(`${failed} modification(s) échouée(s)`);
|
||||
setSelected(new Set());
|
||||
setBulkPromo("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur");
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="page-content">
|
||||
<h2 class="page-title">Gestion des Élèves</h2>
|
||||
@@ -93,6 +180,44 @@ export default function ConsultStudents() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedInView.length > 0 && (
|
||||
<div class="bulk-bar">
|
||||
<span class="bulk-count">
|
||||
{selectedInView.length} sélectionné(s)
|
||||
</span>
|
||||
<div class="bulk-actions">
|
||||
<select
|
||||
class="filter-select"
|
||||
value={bulkPromo}
|
||||
onChange={(e) =>
|
||||
setBulkPromo((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">Changer de promo…</option>
|
||||
{promos.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
disabled={!bulkPromo || bulkBusy}
|
||||
onClick={bulkChangePromo}
|
||||
>
|
||||
Appliquer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
disabled={bulkBusy}
|
||||
onClick={bulkDelete}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading
|
||||
? <p class="state-loading">Chargement…</p>
|
||||
: (
|
||||
@@ -100,6 +225,13 @@ export default function ConsultStudents() {
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2.5rem">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected && filtered.length > 0}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<th>N° étud.</th>
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
@@ -111,13 +243,23 @@ export default function ConsultStudents() {
|
||||
{filtered.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colspan={5} class="state-empty">
|
||||
<td colspan={6} class="state-empty">
|
||||
Aucun élève trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filtered.map((s) => (
|
||||
<tr key={s.numEtud}>
|
||||
<tr
|
||||
key={s.numEtud}
|
||||
class={selected.has(s.numEtud) ? "row-selected" : ""}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(s.numEtud)}
|
||||
onChange={() => toggleOne(s.numEtud)}
|
||||
/>
|
||||
</td>
|
||||
<td class="col-dim">{s.numEtud}</td>
|
||||
<td>{s.nom}</td>
|
||||
<td>{s.prenom}</td>
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs";
|
||||
import { useRef } from "preact/hooks";
|
||||
import { useSignal } from "@preact/signals";
|
||||
import ImportResultPopup, {
|
||||
type ImportDetail,
|
||||
type ImportResult,
|
||||
} from "$root/defaults/ImportResultPopup.tsx";
|
||||
|
||||
export default function UploadStudents() {
|
||||
const file = useSignal<File | null>(null);
|
||||
const dragging = useSignal(false);
|
||||
const uploading = useSignal(false);
|
||||
const error = useSignal<string | null>(null);
|
||||
const success = useSignal<string | null>(null);
|
||||
const importResult = useSignal<ImportResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function pickFile(f: File) {
|
||||
@@ -18,7 +22,7 @@ export default function UploadStudents() {
|
||||
}
|
||||
file.value = f;
|
||||
error.value = null;
|
||||
success.value = null;
|
||||
importResult.value = null;
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
@@ -46,36 +50,58 @@ export default function UploadStudents() {
|
||||
if (!file.value) return;
|
||||
uploading.value = true;
|
||||
error.value = null;
|
||||
success.value = null;
|
||||
importResult.value = null;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.value.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
let added = 0;
|
||||
let errors = 0;
|
||||
const details: ImportDetail[] = [];
|
||||
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json<{
|
||||
numEtud: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
}>(sheet, { header: ["numEtud", "nom", "prenom"], range: 1 });
|
||||
numEtud: number;
|
||||
idPromo: string;
|
||||
}>(sheet, {
|
||||
header: ["nom", "prenom", "numEtud", "idPromo"],
|
||||
range: 2,
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
const res = await fetch("/students/api/students", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...row, idPromo: sheetName }),
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (res.ok) imported++;
|
||||
else failed++;
|
||||
if (res.ok) {
|
||||
added++;
|
||||
details.push({
|
||||
type: "change",
|
||||
message:
|
||||
`${row.numEtud} : ${row.nom} ${row.prenom} -> ${row.idPromo}`,
|
||||
});
|
||||
} else {
|
||||
errors++;
|
||||
const body = await res.json().catch(() => ({}));
|
||||
details.push({
|
||||
type: "error",
|
||||
message: `${row.numEtud} : ${body.error ?? "Erreur creation"}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
success.value = `Import terminé — ${imported} ajouté${
|
||||
imported !== 1 ? "s" : ""
|
||||
}${failed > 0 ? `, ${failed} erreur${failed !== 1 ? "s" : ""}` : ""}`;
|
||||
importResult.value = {
|
||||
added,
|
||||
modified: 0,
|
||||
ignored: 0,
|
||||
errors,
|
||||
details,
|
||||
};
|
||||
} catch {
|
||||
error.value = "Erreur lors de la lecture du fichier.";
|
||||
} finally {
|
||||
@@ -84,10 +110,7 @@ export default function UploadStudents() {
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
const wb = XLSX.utils.book_new();
|
||||
const ws = XLSX.utils.aoa_to_sheet([["numEtud", "nom", "prenom"]]);
|
||||
XLSX.utils.book_append_sheet(wb, ws, "4A22");
|
||||
XLSX.writeFile(wb, "modele_etudiants.xlsx");
|
||||
globalThis.open("/templates/modele_etudiants.xlsx", "_blank");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -117,10 +140,12 @@ export default function UploadStudents() {
|
||||
</div>
|
||||
|
||||
{error.value && <p class="state-error">{error.value}</p>}
|
||||
{success.value && (
|
||||
<p style="font-size:0.82rem; color: light-dark(var(--light-accent-color), var(--dark-accent-color)); margin-bottom: 0.75rem">
|
||||
{success.value}
|
||||
</p>
|
||||
|
||||
{importResult.value && (
|
||||
<ImportResultPopup
|
||||
result={importResult.value}
|
||||
onClose={() => (importResult.value = null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class="upload-actions">
|
||||
@@ -142,9 +167,8 @@ export default function UploadStudents() {
|
||||
</div>
|
||||
|
||||
<p class="upload-format">
|
||||
Format : <strong>promo</strong> (nom de la feuille) |{" "}
|
||||
<strong>numEtud</strong> | <strong>nom</strong> |{" "}
|
||||
<strong>prénom</strong>
|
||||
Format : <strong>Nom</strong> | <strong>Prenom</strong> |{" "}
|
||||
<strong>Numero-etudiant</strong> | <strong>Promotion</strong>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,10 +6,9 @@ const properties: AppProperties = {
|
||||
pages: {
|
||||
index: "Accueil",
|
||||
consult: "Élèves",
|
||||
promotions: "Promotions",
|
||||
upload: "Import xlsx",
|
||||
},
|
||||
adminOnly: ["consult", "promotions", "upload"],
|
||||
adminOnly: ["consult", "upload"],
|
||||
hint: "Create students promotion and see informations",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { promotions } from "$root/databases/schema.ts";
|
||||
import {
|
||||
ajustements,
|
||||
enseignements,
|
||||
modules,
|
||||
notes,
|
||||
promotions,
|
||||
students,
|
||||
ueModules,
|
||||
ues,
|
||||
} from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource 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> = {
|
||||
// #15 GET /promotions/{idPromo}
|
||||
@@ -18,7 +28,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const promo = await db
|
||||
@@ -27,7 +37,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(promotions.id, context.params.idPromo))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!promo) return NOT_FOUND;
|
||||
if (!promo) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(promo), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -40,7 +50,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const body: { annee: string } = await request.json();
|
||||
@@ -51,7 +61,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(promotions.id, context.params.idPromo))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND;
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -59,20 +69,104 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
},
|
||||
|
||||
// #17 DELETE /promotions/{idPromo}
|
||||
// Blocked if students are still assigned (409).
|
||||
// Cascade: deletes linked ue_modules, enseignements, and orphaned
|
||||
// modules (+ their notes) & UEs (+ their ajustements).
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(promotions)
|
||||
.where(eq(promotions.id, context.params.idPromo))
|
||||
.returning();
|
||||
const idPromo = context.params.idPromo;
|
||||
|
||||
if (!deleted) return NOT_FOUND;
|
||||
const promo = await db
|
||||
.select()
|
||||
.from(promotions)
|
||||
.where(eq(promotions.id, idPromo))
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!promo) return NOT_FOUND();
|
||||
|
||||
// Block deletion if students are still assigned
|
||||
const assignedStudents = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.idPromo, idPromo))
|
||||
.then((r) => r.length);
|
||||
|
||||
if (assignedStudents > 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
`Impossible de supprimer : ${assignedStudents} étudiant(s) encore assigné(s) à cette promotion`,
|
||||
}),
|
||||
{ status: 409, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Collect linked module IDs and UE IDs before deleting junction rows
|
||||
const linkedUeModules = await tx
|
||||
.select({ idModule: ueModules.idModule, idUE: ueModules.idUE })
|
||||
.from(ueModules)
|
||||
.where(eq(ueModules.idPromo, idPromo));
|
||||
|
||||
const linkedEns = await tx
|
||||
.select({ idModule: enseignements.idModule })
|
||||
.from(enseignements)
|
||||
.where(eq(enseignements.idPromo, idPromo));
|
||||
|
||||
const moduleIds = [
|
||||
...new Set([
|
||||
...linkedUeModules.map((um) => um.idModule),
|
||||
...linkedEns.map((e) => e.idModule),
|
||||
]),
|
||||
];
|
||||
const ueIds = [...new Set(linkedUeModules.map((um) => um.idUE))];
|
||||
|
||||
// Delete junction rows that directly reference this promo
|
||||
await tx.delete(ueModules).where(eq(ueModules.idPromo, idPromo));
|
||||
await tx.delete(enseignements).where(eq(enseignements.idPromo, idPromo));
|
||||
|
||||
// Delete orphaned modules (not used by another promo) and their notes
|
||||
for (const modId of moduleIds) {
|
||||
const stillInUeModules = await tx
|
||||
.select()
|
||||
.from(ueModules)
|
||||
.where(eq(ueModules.idModule, modId))
|
||||
.then((r) => r.length > 0);
|
||||
const stillInEns = await tx
|
||||
.select()
|
||||
.from(enseignements)
|
||||
.where(eq(enseignements.idModule, modId))
|
||||
.then((r) => r.length > 0);
|
||||
|
||||
if (!stillInUeModules && !stillInEns) {
|
||||
await tx.delete(notes).where(eq(notes.idModule, modId));
|
||||
await tx.delete(modules).where(eq(modules.id, modId));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned UEs (not used by another promo) and their ajustements
|
||||
for (const ueId of ueIds) {
|
||||
const stillUsed = await tx
|
||||
.select()
|
||||
.from(ueModules)
|
||||
.where(eq(ueModules.idUE, ueId))
|
||||
.then((r) => r.length > 0);
|
||||
|
||||
if (!stillUsed) {
|
||||
await tx.delete(ajustements).where(eq(ajustements.idUE, ueId));
|
||||
await tx.delete(ues).where(eq(ues.id, ueId));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the promotion
|
||||
await tx.delete(promotions).where(eq(promotions.id, idPromo));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
|
||||
@@ -44,13 +44,25 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
idPromo: string;
|
||||
} = await request.json();
|
||||
|
||||
if (!body.nom || !body.prenom || !body.idPromo) {
|
||||
if (!body.nom || !body.prenom) {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
const values: {
|
||||
numEtud?: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
idPromo?: string;
|
||||
} = {
|
||||
nom: body.nom,
|
||||
prenom: body.prenom,
|
||||
};
|
||||
if (body.numEtud) values.numEtud = body.numEtud;
|
||||
if (body.idPromo) values.idPromo = body.idPromo;
|
||||
|
||||
const [created] = await db
|
||||
.insert(students)
|
||||
.values({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
|
||||
.values(values)
|
||||
.returning();
|
||||
|
||||
return new Response(JSON.stringify(created), {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import { db } from "$root/databases/db.ts";
|
||||
import { students } from "$root/databases/schema.ts";
|
||||
import {
|
||||
ajustements,
|
||||
mobility,
|
||||
notes,
|
||||
students,
|
||||
} from "$root/databases/schema.ts";
|
||||
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
|
||||
import { eq } from "npm:drizzle-orm@0.45.2";
|
||||
|
||||
const NOT_FOUND = new Response(
|
||||
JSON.stringify({ error: "Ressource introuvable" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
const NOT_FOUND = () =>
|
||||
new Response(
|
||||
JSON.stringify({ error: "Ressource 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> = {
|
||||
// #10 GET /students/{numEtud}
|
||||
@@ -18,7 +24,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);
|
||||
@@ -28,7 +34,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!student) return NOT_FOUND;
|
||||
if (!student) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(student), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -41,20 +47,32 @@ 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);
|
||||
const body: { nom: string; prenom: string; idPromo: string } = await request
|
||||
.json();
|
||||
const body: { nom?: string; prenom?: string; idPromo?: string } =
|
||||
await request.json();
|
||||
|
||||
const set: { nom?: string; prenom?: string; idPromo?: string } = {};
|
||||
if (body.nom !== undefined) set.nom = body.nom;
|
||||
if (body.prenom !== undefined) set.prenom = body.prenom;
|
||||
if (body.idPromo !== undefined) set.idPromo = body.idPromo;
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Au moins un champ requis" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(students)
|
||||
.set({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
|
||||
.set(set)
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.returning();
|
||||
|
||||
if (!updated) return NOT_FOUND;
|
||||
if (!updated) return NOT_FOUND();
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -62,21 +80,31 @@ export const handler: Handlers<null, AuthenticatedState> = {
|
||||
},
|
||||
|
||||
// #12 DELETE /students/{numEtud}
|
||||
// Cascade: deletes notes, ajustements, mobility for this student.
|
||||
async DELETE(
|
||||
_request: Request,
|
||||
context: FreshContext<AuthenticatedState>,
|
||||
): Promise<Response> {
|
||||
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
|
||||
return FORBIDDEN;
|
||||
return FORBIDDEN();
|
||||
}
|
||||
|
||||
const numEtud = Number(context.params.numEtud);
|
||||
const [deleted] = await db
|
||||
.delete(students)
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.returning();
|
||||
|
||||
if (!deleted) return NOT_FOUND;
|
||||
const student = await db
|
||||
.select()
|
||||
.from(students)
|
||||
.where(eq(students.numEtud, numEtud))
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!student) return NOT_FOUND();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(notes).where(eq(notes.numEtud, numEtud));
|
||||
await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud));
|
||||
await tx.delete(mobility).where(eq(mobility.studentId, numEtud));
|
||||
await tx.delete(students).where(eq(students.numEtud, numEtud));
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
getPartialsConfig,
|
||||
makePartials,
|
||||
} from "$root/defaults/makePartials.tsx";
|
||||
import { FreshContext } from "$fresh/server.ts";
|
||||
import { State } from "$root/defaults/interfaces.ts";
|
||||
import AdminPromotions from "../../(_islands)/AdminPromotions.tsx";
|
||||
|
||||
// deno-lint-ignore require-await
|
||||
async function Promotions(
|
||||
_request: Request,
|
||||
_context: FreshContext<State>,
|
||||
) {
|
||||
return <AdminPromotions />;
|
||||
}
|
||||
|
||||
export const config = getPartialsConfig();
|
||||
export default makePartials(Promotions);
|
||||
Reference in New Issue
Block a user