Version initiale — carte des établissements scolaires

Carte Leaflet (annuaire Éducation nationale) + API PHP/MySQL :
fiche établissement (effectifs, IPS, population INSEE, DPE ADEME),
GED documents/photos, commentaires par groupe, gestion utilisateurs
et politique de mots de passe. Imports opendata (effectifs, IPS,
population, DPE, IRIS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:35:41 +02:00
commit 5633969d98
40 changed files with 7242 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
<?php
/**
* Éléments d'un établissement : fichiers, photos, liens.
*
* GET ?uai=UAI [&kind=] → éléments VALIDÉS (public)
* GET ?action=queue → file d'attente de modération (admin, selon périmètre)
* POST ?uai=UAI (multipart) → soumission par tout internaute (statut « pending »)
* champs : kind, folder_id?, title?, description?, url?,
* submitter_name?, submitter_email?
* fichier : « file » (pour kind=file|photo)
* POST ?action=validate (JSON) → {id, decision:'approve'|'reject', reason?} (admin)
* POST ?action=update (JSON) → {id, title, description?} édition (valideurs)
* DELETE ?id=N → suppression (admin, selon périmètre)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
/** Sous-dossier d'upload selon le type. */
function kind_dir(string $kind): string
{
return $kind === 'photo' ? 'photos' : 'files';
}
/** URL publique d'un élément stocké sur disque. */
function item_url(array $r): ?string
{
if ($r['kind'] === 'link') {
return $r['url'];
}
if (!$r['filename']) {
return null;
}
return 'uploads/' . kind_dir($r['kind']) . '/' . rawurlencode($r['filename']);
}
function shape_item(array $r): array
{
return [
'id' => (int) $r['id'],
'folder_id' => $r['folder_id'] !== null ? (int) $r['folder_id'] : null,
'kind' => $r['kind'],
'title' => $r['title'],
'description' => $r['description'] ?? null,
'url' => item_url($r),
'size' => $r['size'] !== null ? (int) $r['size'] : null,
'mime' => $r['mime'],
'status' => $r['status'],
'submitted_at' => $r['submitted_at'] ?? null,
];
}
try {
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// ---- File d'attente de modération (admin) -----------------------------
if ($method === 'GET' && $action === 'queue') {
$user = require_login();
if ($user['role'] !== 'super' && $user['role'] !== 'perimeter') {
json_out([]); // commentateur : aucune file de modération
}
$sql = "SELECT i.*, e.nom AS etab_nom, e.com AS etab_com, e.depc AS etab_depc, e.dep AS etab_dep
FROM items i
JOIN etablissements e ON e.id = i.uai";
$args = [];
if ($user['role'] === 'super') {
$sql .= " WHERE i.status = 'pending'";
} else {
// Périmètre personnel (legacy) OU groupe « valideur » de l'utilisateur.
$sql .= " WHERE i.status = 'pending' AND (
EXISTS (SELECT 1 FROM user_scopes s
WHERE s.user_id = ? AND s.com = e.com AND s.depc = e.depc)
OR EXISTS (SELECT 1 FROM group_members gm
JOIN user_groups g ON g.id = gm.group_id AND g.can_validate = 1
JOIN group_scopes gs ON gs.group_id = g.id AND gs.com = e.com AND gs.depc = e.depc
WHERE gm.user_id = ?))";
$args[] = $user['id'];
$args[] = $user['id'];
}
$sql .= ' ORDER BY i.submitted_at ASC';
$st = db()->prepare($sql);
$st->execute($args);
$out = [];
foreach ($st as $r) {
$out[] = shape_item($r) + [
'uai' => $r['uai'],
'etab' => $r['etab_nom'] . ' — ' . $r['etab_com'] . ' (' . $r['etab_depc'] . ')',
'submitter_name' => $r['submitter_name'],
'submitter_email' => $r['submitter_email'],
];
}
json_out($out);
}
// ---- Liste publique (éléments validés) --------------------------------
if ($method === 'GET') {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$sql = "SELECT * FROM items WHERE uai = ? AND status = 'approved'";
$args = [$uai];
if (!empty($_GET['kind'])) {
$sql .= ' AND kind = ?';
$args[] = $_GET['kind'];
}
$sql .= ' ORDER BY submitted_at DESC, id DESC';
$st = db()->prepare($sql);
$st->execute($args);
$out = [];
foreach ($st as $r) {
$out[] = shape_item($r);
}
json_out($out);
}
// ---- Validation / rejet (admin) ---------------------------------------
if ($method === 'POST' && $action === 'validate') {
$user = require_login();
$b = body_json();
$id = (int) ($b['id'] ?? 0);
$decision = $b['decision'] ?? '';
if ($id <= 0 || !in_array($decision, ['approve', 'reject'], true)) {
json_error('Paramètres invalides (id, decision).');
}
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
$st->execute([$id]);
$uai = $st->fetchColumn();
if ($uai === false) {
json_error('Élément introuvable.', 404);
}
if (!can_validate_uai($user, (string) $uai)) {
json_error('Hors de votre périmètre.', 403);
}
$status = $decision === 'approve' ? 'approved' : 'rejected';
$reason = $decision === 'reject' ? (trim((string) ($b['reason'] ?? '')) ?: null) : null;
db()->prepare(
'UPDATE items SET status = ?, reject_reason = ?, validated_by = ?, validated_at = NOW() WHERE id = ?'
)->execute([$status, $reason, $user['id'], $id]);
json_out(['ok' => true, 'id' => $id, 'status' => $status]);
}
// ---- Édition du libellé / description (valideurs) ---------------------
if ($method === 'POST' && $action === 'update') {
$user = require_login();
$b = body_json();
$id = (int) ($b['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
$st->execute([$id]);
$uai = $st->fetchColumn();
if ($uai === false) {
json_error('Élément introuvable.', 404);
}
if (!can_validate_uai($user, (string) $uai)) {
json_error('Hors de votre périmètre.', 403);
}
$title = trim((string) ($b['title'] ?? ''));
$description = trim((string) ($b['description'] ?? ''));
if ($title === '') {
json_error('Le libellé ne peut pas être vide.');
}
db()->prepare('UPDATE items SET title = ?, description = ? WHERE id = ?')
->execute([$title, $description !== '' ? $description : null, $id]);
json_out(['ok' => true, 'id' => $id]);
}
// ---- Soumission (tout internaute) -------------------------------------
if ($method === 'POST') {
$uai = trim((string) ($_GET['uai'] ?? $_POST['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$chk = db()->prepare('SELECT 1 FROM etablissements WHERE id = ?');
$chk->execute([$uai]);
if (!$chk->fetchColumn()) {
json_error('Établissement inconnu.', 404);
}
$kind = $_POST['kind'] ?? '';
if (!in_array($kind, ['file', 'photo', 'link'], true)) {
json_error('Type invalide (file, photo ou link).');
}
$folderId = (isset($_POST['folder_id']) && $_POST['folder_id'] !== '')
? (int) $_POST['folder_id'] : null;
if ($folderId !== null) {
$f = db()->prepare('SELECT 1 FROM folders WHERE id = ?');
$f->execute([$folderId]);
if (!$f->fetchColumn()) {
json_error('Dossier inconnu.');
}
}
$title = trim((string) ($_POST['title'] ?? ''));
$description = trim((string) ($_POST['description'] ?? '')) ?: null;
$submitterName = trim((string) ($_POST['submitter_name'] ?? '')) ?: null;
$submitterMail = trim((string) ($_POST['submitter_email'] ?? '')) ?: null;
$filename = null;
$url = null;
$size = null;
$mime = null;
if ($kind === 'link') {
$url = trim((string) ($_POST['url'] ?? ''));
if (!preg_match('#^https?://#i', $url)) {
json_error('URL invalide (doit commencer par http:// ou https://).');
}
if ($title === '') {
$title = $url;
}
} else {
if (empty($_FILES['file']) || (int) $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
json_error('Fichier requis.');
}
$tmp = $_FILES['file']['tmp_name'];
$orig = (string) $_FILES['file']['name'];
$size = (int) $_FILES['file']['size'];
$mime = mime_content_type($tmp) ?: ($_FILES['file']['type'] ?? 'application/octet-stream');
if ($kind === 'photo' && strncmp($mime, 'image/', 6) !== 0) {
json_error('Le fichier nest pas une image.');
}
$dir = dirname(__DIR__) . '/uploads/' . kind_dir($kind);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', pathinfo($orig, PATHINFO_EXTENSION) ?: ''));
$filename = bin2hex(random_bytes(8)) . ($ext !== '' ? '.' . $ext : '');
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
json_error('Échec de lenregistrement du fichier.', 500);
}
if ($title === '') {
$title = $orig;
}
}
db()->prepare(
'INSERT INTO items (uai, folder_id, kind, title, description, filename, url, size, mime, status, submitter_name, submitter_email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, \'pending\', ?, ?)'
)->execute([$uai, $folderId, $kind, $title, $description, $filename, $url, $size, $mime, $submitterName, $submitterMail]);
json_out(['ok' => true, 'id' => (int) db()->lastInsertId(), 'status' => 'pending'], 201);
}
// ---- Suppression (admin) ----------------------------------------------
if ($method === 'DELETE') {
$user = require_login();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$st = db()->prepare('SELECT uai, kind, filename FROM items WHERE id = ?');
$st->execute([$id]);
$row = $st->fetch();
if (!$row) {
json_error('Élément introuvable.', 404);
}
if (!can_validate_uai($user, (string) $row['uai'])) {
json_error('Hors de votre périmètre.', 403);
}
db()->prepare('DELETE FROM comments WHERE item_id = ?')->execute([$id]);
db()->prepare('DELETE FROM items WHERE id = ?')->execute([$id]);
if ($row['filename']) {
@unlink(dirname(__DIR__) . '/uploads/' . kind_dir($row['kind']) . '/' . $row['filename']);
}
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}