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
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Authentification et gestion du mot de passe.
* GET ?action=me → utilisateur courant (ou null)
* POST ?action=login → {username, password}
* POST ?action=logout
* POST ?action=change_password → {current, new} (utilisateur connecté)
* POST ?action=forgot → {login} (identifiant ou e-mail)
* POST ?action=reset → {token, new} (lien de réinitialisation)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
const LOGIN_MAX_ATTEMPTS = 5; // échecs avant verrouillage
const LOGIN_LOCK_MINUTES = 15; // durée du verrou
const RESET_TTL_MINUTES = 60; // validité d'un lien de réinitialisation
try {
$action = $_GET['action'] ?? 'me';
if ($action === 'me') {
json_out(['user' => current_user()]);
}
// ---- Connexion --------------------------------------------------------
if ($action === 'login') {
$b = body_json();
$username = trim((string) ($b['username'] ?? ''));
$password = (string) ($b['password'] ?? '');
if ($username === '' || $password === '') {
json_error('Identifiant et mot de passe requis.');
}
$st = db()->prepare('SELECT id, username, password_hash, role, must_change, failed_attempts, locked_until FROM users WHERE username = ?');
$st->execute([$username]);
$u = $st->fetch();
// Verrou anti-bruteforce.
if ($u && $u['locked_until'] !== null && strtotime($u['locked_until']) > time()) {
json_error('Compte temporairement verrouillé suite à trop dessais. Réessayez plus tard.', 429);
}
if (!$u || !password_verify($password, $u['password_hash'])) {
if ($u) {
$attempts = (int) $u['failed_attempts'] + 1;
if ($attempts >= LOGIN_MAX_ATTEMPTS) {
db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = DATE_ADD(NOW(), INTERVAL ? MINUTE) WHERE id = ?')
->execute([LOGIN_LOCK_MINUTES, (int) $u['id']]);
} else {
db()->prepare('UPDATE users SET failed_attempts = ? WHERE id = ?')
->execute([$attempts, (int) $u['id']]);
}
}
json_error('Identifiants invalides.', 401);
}
// Succès : réinitialise les compteurs et régénère la session.
db()->prepare('UPDATE users SET failed_attempts = 0, locked_until = NULL WHERE id = ?')
->execute([(int) $u['id']]);
session_boot();
session_regenerate_id(true);
$_SESSION['uid'] = (int) $u['id'];
json_out(['user' => [
'id' => (int) $u['id'],
'username' => $u['username'],
'role' => $u['role'],
'must_change' => (int) $u['must_change'],
]]);
}
if ($action === 'logout') {
session_boot();
$_SESSION = [];
session_destroy();
json_out(['ok' => true]);
}
// ---- Changement de mot de passe (connecté) ----------------------------
if ($action === 'change_password') {
$me = require_login();
$b = body_json();
$current = (string) ($b['current'] ?? '');
$new = (string) ($b['new'] ?? '');
$st = db()->prepare('SELECT password_hash FROM users WHERE id = ?');
$st->execute([(int) $me['id']]);
$hash = $st->fetchColumn();
if (!$hash || !password_verify($current, $hash)) {
json_error('Mot de passe actuel incorrect.', 403);
}
if ($new === $current) {
json_error('Le nouveau mot de passe doit être différent de lactuel.');
}
if ($err = password_policy_check($new)) {
json_error($err);
}
db()->prepare('UPDATE users SET password_hash = ?, must_change = 0 WHERE id = ?')
->execute([password_hash($new, PASSWORD_DEFAULT), (int) $me['id']]);
json_out(['ok' => true]);
}
// ---- Mot de passe oublié : demande de lien ----------------------------
if ($action === 'forgot') {
$login = trim((string) (body_json()['login'] ?? ''));
// Réponse générique systématique (ne révèle pas l'existence d'un compte).
$generic = ['ok' => true, 'message' => 'Si un compte correspond, un e-mail de réinitialisation a été envoyé.'];
if ($login === '') {
json_out($generic);
}
$st = db()->prepare('SELECT id, username, email FROM users WHERE username = ? OR (email IS NOT NULL AND email = ?) LIMIT 1');
$st->execute([$login, $login]);
$u = $st->fetch();
if ($u && !empty($u['email'])) {
// Invalide les anciens jetons non utilisés puis en crée un nouveau.
db()->prepare('UPDATE password_resets SET used = 1 WHERE user_id = ? AND used = 0')->execute([(int) $u['id']]);
$token = reset_token_new();
db()->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))')
->execute([(int) $u['id'], hash('sha256', $token), RESET_TTL_MINUTES]);
$link = APP_BASE_URL . '/reset.html?token=' . $token;
$body = "Bonjour,\n\nUne réinitialisation du mot de passe du compte « {$u['username']} » a été demandée.\n"
. "Cliquez sur ce lien (valable " . RESET_TTL_MINUTES . " minutes) pour choisir un nouveau mot de passe :\n\n$link\n\n"
. "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail.\n";
send_mail($u['email'], 'Réinitialisation de votre mot de passe', $body);
}
json_out($generic);
}
// ---- Mot de passe oublié : application du nouveau mot de passe ---------
if ($action === 'reset') {
$b = body_json();
$token = (string) ($b['token'] ?? '');
$new = (string) ($b['new'] ?? '');
if ($token === '') {
json_error('Lien invalide.');
}
$st = db()->prepare('SELECT id, user_id FROM password_resets WHERE token_hash = ? AND used = 0 AND expires_at > NOW() LIMIT 1');
$st->execute([hash('sha256', $token)]);
$row = $st->fetch();
if (!$row) {
json_error('Lien de réinitialisation invalide ou expiré.', 400);
}
if ($err = password_policy_check($new)) {
json_error($err);
}
db()->prepare('UPDATE users SET password_hash = ?, must_change = 0, failed_attempts = 0, locked_until = NULL WHERE id = ?')
->execute([password_hash($new, PASSWORD_DEFAULT), (int) $row['user_id']]);
db()->prepare('UPDATE password_resets SET used = 1 WHERE id = ?')->execute([(int) $row['id']]);
json_out(['ok' => true]);
}
json_error('Action inconnue.', 404);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Commentaires — sur une fiche d'établissement OU sur un document précis.
*
* Mêmes règles dans les deux cas : un commentaire est horodaté, porte un texte et
* le nom de son auteur, et n'est visible que par les membres d'un même groupe que
* l'auteur (le super-admin voit tout). Poster nécessite d'appartenir à au moins un
* groupe. L'auteur ou le super-admin peuvent supprimer ; pas d'édition.
*
* Portée : ?uai=UAI (établissement, commentaires « généraux ») ou
* ?item_id=N (document précis). Un commentaire de document n'apparaît pas
* dans les commentaires d'établissement et inversement.
*
* GET ?uai=UAI → { auth, username, role, can_post, can_validate, comments[] }
* GET ?item_id=N → { auth, username, role, can_post, comments[] }
* POST ?uai=UAI | ?item_id=N {body} → crée un commentaire (membre d'un groupe requis)
* DELETE ?id=N → supprime (auteur ou super-admin)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$method = $_SERVER['REQUEST_METHOD'];
$itemId = isset($_GET['item_id']) && $_GET['item_id'] !== '' ? (int) $_GET['item_id'] : 0;
// ---- Liste des commentaires visibles ----------------------------------
if ($method === 'GET') {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($itemId <= 0 && $uai === '') {
json_error('Paramètre uai ou item_id requis.');
}
$me = current_user();
if (!$me) {
json_out(['auth' => false, 'can_post' => false, 'comments' => []]);
}
$isSuper = $me['role'] === 'super';
$canPost = $isSuper || !empty(user_group_ids((int) $me['id']));
// Clause de portée : un document précis, ou l'établissement (hors documents).
if ($itemId > 0) {
$scopeSql = 'c.item_id = ?';
$scopeArg = $itemId;
} else {
$scopeSql = 'c.uai = ? AND c.item_id IS NULL';
$scopeArg = $uai;
}
// Super : tout ; sinon ses propres commentaires + ceux d'un membre partageant
// au moins un groupe avec lui.
if ($isSuper) {
$sql = "SELECT c.id, c.user_id, c.body, c.created_at, u.username
FROM comments c JOIN users u ON u.id = c.user_id
WHERE $scopeSql ORDER BY c.created_at DESC";
$st = db()->prepare($sql);
$st->execute([$scopeArg]);
} else {
$sql = "SELECT c.id, c.user_id, c.body, c.created_at, u.username
FROM comments c JOIN users u ON u.id = c.user_id
WHERE $scopeSql AND (
c.user_id = ?
OR EXISTS (SELECT 1 FROM group_members a
JOIN group_members b ON a.group_id = b.group_id
WHERE a.user_id = ? AND b.user_id = c.user_id))
ORDER BY c.created_at DESC";
$st = db()->prepare($sql);
$st->execute([$scopeArg, (int) $me['id'], (int) $me['id']]);
}
$out = [];
foreach ($st as $r) {
$out[] = [
'id' => (int) $r['id'],
'username' => $r['username'],
'body' => $r['body'],
'created_at' => $r['created_at'],
'mine' => (int) $r['user_id'] === (int) $me['id'],
];
}
$resp = [
'auth' => true,
'username' => $me['username'],
'role' => $me['role'],
'can_post' => $canPost,
'comments' => $out,
];
// Au niveau établissement, indique si l'utilisateur peut éditer les documents.
if ($itemId <= 0) {
$resp['can_validate'] = can_validate_uai($me, $uai);
}
json_out($resp);
}
// ---- Dépôt d'un commentaire -------------------------------------------
if ($method === 'POST') {
$me = require_login();
if ($me['role'] !== 'super' && empty(user_group_ids((int) $me['id']))) {
json_error('Vous devez appartenir à un groupe pour commenter.', 403);
}
$body = trim((string) (body_json()['body'] ?? ''));
if ($body === '') {
json_error('Le commentaire est vide.');
}
if ($itemId > 0) {
// Commentaire sur un document : on récupère l'UAI de l'élément.
$st = db()->prepare('SELECT uai FROM items WHERE id = ?');
$st->execute([$itemId]);
$uai = $st->fetchColumn();
if ($uai === false) {
json_error('Document introuvable.', 404);
}
db()->prepare('INSERT INTO comments (uai, item_id, user_id, body) VALUES (?, ?, ?, ?)')
->execute([$uai, $itemId, (int) $me['id'], $body]);
} else {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai ou item_id requis.');
}
$chk = db()->prepare('SELECT 1 FROM etablissements WHERE id = ?');
$chk->execute([$uai]);
if (!$chk->fetchColumn()) {
json_error('Établissement inconnu.', 404);
}
db()->prepare('INSERT INTO comments (uai, user_id, body) VALUES (?, ?, ?)')
->execute([$uai, (int) $me['id'], $body]);
}
json_out(['ok' => true, 'id' => (int) db()->lastInsertId()], 201);
}
// ---- Suppression (auteur ou super) ------------------------------------
if ($method === 'DELETE') {
$me = require_login();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$st = db()->prepare('SELECT user_id FROM comments WHERE id = ?');
$st->execute([$id]);
$owner = $st->fetchColumn();
if ($owner === false) {
json_error('Commentaire introuvable.', 404);
}
if ($me['role'] !== 'super' && (int) $owner !== (int) $me['id']) {
json_error('Vous ne pouvez supprimer que vos propres commentaires.', 403);
}
db()->prepare('DELETE FROM comments WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* Autocomplétion des communes (pour affecter un périmètre à un admin).
* GET ?q=... → jusqu'à 30 couples commune + département distincts.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
require_super();
$q = trim((string) ($_GET['q'] ?? ''));
if (strlen($q) < 2) {
json_out([]);
}
$st = db()->prepare(
'SELECT com, depc, dep, COUNT(*) AS nb
FROM etablissements
WHERE com LIKE ?
GROUP BY com, depc, dep
ORDER BY com
LIMIT 30'
);
$st->execute([$q . '%']);
$out = [];
foreach ($st as $r) {
$out[] = [
'com' => $r['com'],
'depc' => $r['depc'],
'dep' => $r['dep'],
'nb' => (int) $r['nb'],
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+375
View File
@@ -0,0 +1,375 @@
<?php
/**
* Socle commun : connexion PDO MySQL, sorties JSON, session/authentification,
* et import des établissements (partagé entre setup.php et api/import.php).
*
* Identifiants par défaut de Laragon (root / mot de passe vide).
*/
declare(strict_types=1);
const DB_HOST = '127.0.0.1';
const DB_PORT = 3306;
const DB_NAME = 'ecoles';
const DB_USER = 'root';
const DB_PASS = '';
// --- Application & envoi d'e-mails -----------------------------------------
// URL publique de base (pour les liens de réinitialisation). Adaptez si besoin.
const APP_BASE_URL = 'http://ecoles.test';
// Transport du mailer : 'log' (écrit dans private/mail_outbox.log, défaut local et sûr),
// 'mail' (fonction PHP mail()), ou 'smtp' (client SMTP intégré ci-dessous).
const MAIL_TRANSPORT = 'log';
const MAIL_FROM = 'no-reply@ecoles.test';
const MAIL_FROM_NAME = 'Carte des établissements';
// Paramètres SMTP (utilisés seulement si MAIL_TRANSPORT === 'smtp').
const SMTP_HOST = '';
const SMTP_PORT = 587;
const SMTP_USER = '';
const SMTP_PASS = '';
const SMTP_SECURE = 'tls'; // 'tls' (STARTTLS, port 587) | 'ssl' (port 465) | '' (aucun)
/** Connexion partagée à la base applicative. */
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
// ---------------------------------------------------------------------------
// Sorties JSON
// ---------------------------------------------------------------------------
/** Émet une réponse JSON et arrête le script. */
function json_out($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/** Émet une erreur JSON et arrête le script. */
function json_error(string $message, int $status = 400): void
{
json_out(['error' => $message], $status);
}
/** Corps de requête JSON décodé en tableau. */
function body_json(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '', true);
return is_array($data) ? $data : [];
}
// ---------------------------------------------------------------------------
// Session / authentification
// ---------------------------------------------------------------------------
function session_boot(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
/** Utilisateur connecté (id, username, role) ou null. */
function current_user(): ?array
{
static $user = false;
if ($user !== false) {
return $user;
}
session_boot();
if (empty($_SESSION['uid'])) {
return $user = null;
}
$st = db()->prepare('SELECT id, username, role, must_change FROM users WHERE id = ?');
$st->execute([$_SESSION['uid']]);
$row = $st->fetch();
if ($row) {
$row['id'] = (int) $row['id'];
$row['must_change'] = (int) $row['must_change'];
}
return $user = ($row ?: null);
}
/** Exige une connexion ; renvoie l'utilisateur ou répond 401. */
function require_login(): array
{
$u = current_user();
if (!$u) {
json_error('Authentification requise.', 401);
}
return $u;
}
/** Exige le rôle super-admin ; renvoie l'utilisateur ou répond 403. */
function require_super(): array
{
$u = require_login();
if ($u['role'] !== 'super') {
json_error('Réservé à ladministrateur de la plateforme.', 403);
}
return $u;
}
/**
* L'utilisateur peut-il valider/modérer l'établissement $uai ?
* Vrai si super-admin, OU s'il a un périmètre personnel (user_scopes, legacy)
* correspondant, OU s'il est membre d'un groupe « valideur » (user_groups.can_validate)
* dont une commune (group_scopes) correspond à l'établissement.
* Les commentateurs (role « member ») n'ont aucun droit de validation.
*/
function can_validate_uai(array $user, string $uai): bool
{
if ($user['role'] === 'super') {
return true;
}
if ($user['role'] !== 'perimeter') {
return false; // « member » (commentateur) : pas de modération
}
$st = db()->prepare(
'SELECT 1 FROM etablissements e
JOIN user_scopes s ON s.com = e.com AND s.depc = e.depc
WHERE e.id = ? AND s.user_id = ?
LIMIT 1'
);
$st->execute([$uai, $user['id']]);
if ($st->fetchColumn()) {
return true;
}
$st = db()->prepare(
'SELECT 1 FROM etablissements e
JOIN group_members gm ON gm.user_id = ?
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 e.id = ?
LIMIT 1'
);
$st->execute([$user['id'], $uai]);
return (bool) $st->fetchColumn();
}
/** Identifiants des groupes auxquels l'utilisateur appartient. */
function user_group_ids(int $userId): array
{
$st = db()->prepare('SELECT group_id FROM group_members WHERE user_id = ?');
$st->execute([$userId]);
return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
// ---------------------------------------------------------------------------
// Politique de mots de passe
// ---------------------------------------------------------------------------
const PASSWORD_MIN_LEN = 8;
const PASSWORD_MIN_CLASSES = 3; // parmi : minuscule, majuscule, chiffre, caractère spécial
/**
* Vérifie la robustesse d'un mot de passe (≥ 8 caractères et au moins 3 types
* parmi minuscule/majuscule/chiffre/spécial). Renvoie null si conforme, sinon
* un message d'erreur en français.
*/
function password_policy_check(string $pwd): ?string
{
if (strlen($pwd) < PASSWORD_MIN_LEN) {
return 'Le mot de passe doit contenir au moins ' . PASSWORD_MIN_LEN . ' caractères.';
}
$classes = (preg_match('/[a-z]/', $pwd) ? 1 : 0)
+ (preg_match('/[A-Z]/', $pwd) ? 1 : 0)
+ (preg_match('/[0-9]/', $pwd) ? 1 : 0)
+ (preg_match('/[^A-Za-z0-9]/', $pwd) ? 1 : 0);
if ($classes < PASSWORD_MIN_CLASSES) {
return 'Le mot de passe doit combiner au moins ' . PASSWORD_MIN_CLASSES
. ' types parmi : minuscule, majuscule, chiffre, caractère spécial.';
}
return null;
}
/** Jeton de réinitialisation aléatoire (à stocker haché en base). */
function reset_token_new(): string
{
return bin2hex(random_bytes(32));
}
// ---------------------------------------------------------------------------
// Envoi d'e-mails (transport enfichable : log / mail / smtp)
// ---------------------------------------------------------------------------
/** Envoie un e-mail texte. Renvoie true si remis au transport. */
function send_mail(string $to, string $subject, string $body): bool
{
switch (MAIL_TRANSPORT) {
case 'mail':
$headers = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . ">\r\n"
. "Content-Type: text/plain; charset=utf-8\r\n";
return mail($to, $subject, $body, $headers);
case 'smtp':
return smtp_send($to, $subject, $body);
case 'log':
default:
$dir = dirname(__DIR__) . '/private';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$line = "==== " . date('Y-m-d H:i:s') . " ====\n"
. "To: $to\nSubject: $subject\n\n$body\n\n";
return (bool) @file_put_contents($dir . '/mail_outbox.log', $line, FILE_APPEND);
}
}
/** Client SMTP minimal (AUTH LOGIN, STARTTLS ou SSL). Renvoie true si accepté. */
function smtp_send(string $to, string $subject, string $body): bool
{
$host = SMTP_SECURE === 'ssl' ? 'ssl://' . SMTP_HOST : SMTP_HOST;
$fp = @fsockopen($host, SMTP_PORT, $errno, $errstr, 15);
if (!$fp) {
return false;
}
$read = static function () use ($fp): string {
$data = '';
while (($line = fgets($fp, 515)) !== false) {
$data .= $line;
if (strlen($line) < 4 || $line[3] === ' ') {
break;
}
}
return $data;
};
$cmd = static function (string $c) use ($fp, $read): string {
fwrite($fp, $c . "\r\n");
return $read();
};
$ok = static fn(string $r, string $code): bool => strncmp($r, $code, 3) === 0;
$read(); // bannière
$ehlo = 'EHLO ' . (parse_url(APP_BASE_URL, PHP_URL_HOST) ?: 'localhost');
$cmd($ehlo);
if (SMTP_SECURE === 'tls') {
if (!$ok($cmd('STARTTLS'), '220')) {
fclose($fp);
return false;
}
if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($fp);
return false;
}
$cmd($ehlo);
}
if (SMTP_USER !== '') {
$cmd('AUTH LOGIN');
$cmd(base64_encode(SMTP_USER));
if (!$ok($cmd(base64_encode(SMTP_PASS)), '235')) {
fclose($fp);
return false;
}
}
$cmd('MAIL FROM:<' . MAIL_FROM . '>');
$cmd('RCPT TO:<' . $to . '>');
if (!$ok($cmd('DATA'), '354')) {
fclose($fp);
return false;
}
$headers = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . ">\r\n"
. 'To: <' . $to . ">\r\n"
. 'Subject: ' . $subject . "\r\n"
. "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n";
$data = $headers . "\r\n" . str_replace("\r\n.", "\r\n..", $body) . "\r\n.";
$sent = $ok($cmd($data), '250');
$cmd('QUIT');
fclose($fp);
return $sent;
}
// ---------------------------------------------------------------------------
// Import des établissements (opendata)
// ---------------------------------------------------------------------------
/**
* Convertit une ligne brute de l'annuaire opendata en enregistrement compact
* (mêmes clés courtes que data/etablissements.json). Renvoie null si pas de géoloc.
*/
function map_opendata_row(array $r): ?array
{
$lat = $r['latitude'] ?? null;
$lon = $r['longitude'] ?? null;
if ($lat === null || $lon === null) {
return null;
}
$opt = static fn($v) => ((int) ($v ?? 0) === 1) ? 1 : 0;
return [
'id' => $r['identifiant_de_l_etablissement'] ?? null,
'nom' => $r['nom_etablissement'] ?? null,
'type' => $r['type_etablissement'] ?? null,
'stat' => $r['statut_public_prive'] ?? null,
'reg' => $r['libelle_region'] ?? null,
'aca' => $r['libelle_academie'] ?? null,
'dep' => $r['libelle_departement'] ?? null,
'depc' => $r['code_departement'] ?? null,
'com' => $r['nom_commune'] ?? null,
'cp' => $r['code_postal'] ?? null,
'adr' => $r['adresse_1'] ?? null,
'tel' => $r['telephone'] ?? null,
'mail' => $r['mail'] ?? null,
'web' => $r['web'] ?? null,
'etat' => $r['etat'] ?? null,
'circ' => $r['nom_circonscription'] ?? null,
'lat' => round((float) $lat, 5),
'lon' => round((float) $lon, 5),
'rest' => $opt($r['restauration'] ?? 0),
'heb' => $opt($r['hebergement'] ?? 0),
'ulis' => $opt($r['ulis'] ?? 0),
'segp' => $opt($r['segpa'] ?? 0),
'appr' => $opt($r['apprentissage'] ?? 0),
];
}
/**
* Insère/met à jour une liste d'établissements (clés courtes) en base.
* Renvoie le nombre de lignes traitées.
*/
function import_etablissements(PDO $pdo, array $rows): int
{
$sql = 'INSERT INTO etablissements
(id,nom,type,stat,reg,aca,dep,depc,com,cp,adr,tel,mail,web,etat,circ,lat,lon,rest,heb,ulis,segp,appr)
VALUES (:id,:nom,:type,:stat,:reg,:aca,:dep,:depc,:com,:cp,:adr,:tel,:mail,:web,:etat,:circ,:lat,:lon,:rest,:heb,:ulis,:segp,:appr)
ON DUPLICATE KEY UPDATE
nom=VALUES(nom),type=VALUES(type),stat=VALUES(stat),reg=VALUES(reg),aca=VALUES(aca),
dep=VALUES(dep),depc=VALUES(depc),com=VALUES(com),cp=VALUES(cp),adr=VALUES(adr),
tel=VALUES(tel),mail=VALUES(mail),web=VALUES(web),etat=VALUES(etat),circ=VALUES(circ),
lat=VALUES(lat),lon=VALUES(lon),rest=VALUES(rest),heb=VALUES(heb),ulis=VALUES(ulis),
segp=VALUES(segp),appr=VALUES(appr)';
$ins = $pdo->prepare($sql);
$defaults = [
'nom' => null, 'type' => null, 'stat' => null, 'reg' => null, 'aca' => null,
'dep' => null, 'depc' => null, 'com' => null, 'cp' => null, 'adr' => null,
'tel' => null, 'mail' => null, 'web' => null, 'etat' => null, 'circ' => null,
'lat' => null, 'lon' => null, 'rest' => 0, 'heb' => 0, 'ulis' => 0, 'segp' => 0, 'appr' => 0,
];
$pdo->beginTransaction();
$n = 0;
foreach ($rows as $r) {
if (empty($r['id'])) {
continue;
}
$ins->execute(array_merge($defaults, array_intersect_key($r, $defaults), ['id' => $r['id']]));
if (++$n % 5000 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();
return $n;
}
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* DPE (ADEME) rattachés à un établissement, par proximité géographique.
* GET ?uai=UAI → [ { numero_dpe, etiquette_dpe, etiquette_ges, conso, ges,
* surface, periode, cat_erp, date_dpe, date_fin,
* adresse, distance_m }, ... ] trié par distance croissante
* POST ?action=latest body { uais:[...] } → { uai: { dpe, ges } } meilleur match (le + proche)
*
* Rattachement calculé par match_dpe.php (table etab_dpe). Source : import_dpe.php.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
// Batch : meilleur DPE (le plus proche) pour une liste d'UAI → { uai: {dpe, ges} }.
if (($_GET['action'] ?? '') === 'latest') {
$uais = body_json()['uais'] ?? [];
if (!is_array($uais) || !$uais) {
json_out([]);
}
$uais = array_slice(array_values(array_unique(array_map('strval', $uais))), 0, 5000);
$ph = implode(',', array_fill(0, count($uais), '?'));
// Pour chaque UAI, le rattachement de plus petite distance. En cas
// d'ex æquo, la boucle PHP ci-dessous ne garde qu'une ligne par UAI
// (pas de GROUP BY ⇒ compatible only_full_group_by, MySQL et MariaDB).
$sql = "SELECT ed.uai, d.etiquette_dpe, d.etiquette_ges
FROM etab_dpe ed
JOIN dpe d ON d.numero_dpe = ed.numero_dpe
JOIN (SELECT uai, MIN(distance_m) AS dmin
FROM etab_dpe WHERE uai IN ($ph) GROUP BY uai) m
ON m.uai = ed.uai AND m.dmin = ed.distance_m";
$st = db()->prepare($sql);
$st->execute($uais);
$out = [];
foreach ($st as $r) {
$out[$r['uai']] = ['dpe' => $r['etiquette_dpe'], 'ges' => $r['etiquette_ges']];
}
json_out($out);
}
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$st = db()->prepare(
'SELECT d.numero_dpe, d.etiquette_dpe, d.etiquette_ges, d.conso, d.ges,
d.surface, d.periode, d.cat_erp, d.date_dpe, d.date_fin, d.adresse,
ed.distance_m
FROM etab_dpe ed
JOIN dpe d ON d.numero_dpe = ed.numero_dpe
WHERE ed.uai = ?
ORDER BY ed.distance_m'
);
$st->execute([$uai]);
$out = [];
foreach ($st as $r) {
$out[] = [
'numero_dpe' => $r['numero_dpe'],
'etiquette_dpe' => $r['etiquette_dpe'],
'etiquette_ges' => $r['etiquette_ges'],
'conso' => $r['conso'] !== null ? (float) $r['conso'] : null,
'ges' => $r['ges'] !== null ? (float) $r['ges'] : null,
'surface' => $r['surface'] !== null ? (int) $r['surface'] : null,
'periode' => $r['periode'],
'cat_erp' => $r['cat_erp'],
'date_dpe' => $r['date_dpe'],
'date_fin' => $r['date_fin'],
'adresse' => $r['adresse'],
'distance_m' => (int) $r['distance_m'],
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+209
View File
@@ -0,0 +1,209 @@
<?php
/**
* Bibliothèque partagée DPE (ADEME) : création des tables, import du dataset
* tertiaire et rattachement géographique aux établissements.
*
* Utilisée par les scripts CLI (import_dpe.php, match_dpe.php) et par
* l'endpoint web super-admin (api/import_dpe.php), pour éviter toute
* divergence de logique.
*/
declare(strict_types=1);
require_once __DIR__ . '/db.php';
const DPE_DATASET = 'https://data.ademe.fr/data-fair/api/v1/datasets/dpe01tertiaire/lines';
const DPE_FIELDS = 'numero_dpe,etiquette_dpe,etiquette_ges,conso_kwhep_m2_an,'
. 'emission_ges_kg_co2_m2_an,surface_utile,periode_construction,categorie_erp,'
. 'date_etablissement_dpe,date_fin_validite_dpe,code_insee_ban,code_postal_ban,'
. 'adresse_ban,nom_commune_ban,_geopoint';
/** GET JSON via cURL (suit les redirections). */
function dpe_fetch_json(string $url): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_USERAGENT => 'ecoles-import/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Requête échouée : ' . $err);
}
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($code >= 400) {
throw new RuntimeException("HTTP $code sur l'API ADEME.");
}
$data = json_decode($body, true);
if (!is_array($data)) {
throw new RuntimeException('Réponse JSON ADEME invalide.');
}
return $data;
}
/** Crée la table « dpe » si absente. */
function dpe_create_table(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS dpe (
numero_dpe VARCHAR(20) NOT NULL PRIMARY KEY,
etiquette_dpe CHAR(1) NULL,
etiquette_ges CHAR(1) NULL,
conso DECIMAL(10,1) NULL, -- conso_kwhep_m2_an (énergie primaire kWh/m²/an)
ges DECIMAL(10,2) NULL, -- emission_ges_kg_co2_m2_an
surface INT NULL, -- surface_utile (m²)
periode VARCHAR(40) NULL, -- periode_construction
cat_erp VARCHAR(60) NULL, -- categorie_erp
date_dpe DATE NULL, -- date_etablissement_dpe
date_fin DATE NULL, -- date_fin_validite_dpe
code_insee VARCHAR(5) NULL, -- code_insee_ban (clé de rattachement commune)
cp VARCHAR(5) NULL,
adresse VARCHAR(255) NULL,
commune VARCHAR(120) NULL,
lat DECIMAL(9,6) NULL,
lon DECIMAL(9,6) NULL,
INDEX idx_insee (code_insee)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
/**
* Importe les DPE tertiaires de l'ADEME dans la table « dpe ».
* @param string|null $dep filtre département (code) ou null pour tout
* @param callable $log fn(string) pour la progression
* @return int nombre de DPE importés
*/
function dpe_import(PDO $pdo, ?string $dep, callable $log): int
{
dpe_create_table($pdo);
$url = DPE_DATASET . '?size=10000&sort=numero_dpe&select=' . rawurlencode(DPE_FIELDS);
if ($dep !== null && $dep !== '') {
$url .= '&qs=' . rawurlencode('code_departement_ban:"' . $dep . '"');
$log("Filtre département = $dep.");
}
$ins = $pdo->prepare(
'REPLACE INTO dpe
(numero_dpe, etiquette_dpe, etiquette_ges, conso, ges, surface, periode,
cat_erp, date_dpe, date_fin, code_insee, cp, adresse, commune, lat, lon)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
);
$letter = static fn($v) => ($v !== null && $v !== '') ? strtoupper(substr((string) $v, 0, 1)) : null;
$date = static fn($v) => (is_string($v) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) ? $v : null;
$numOrNull = static fn($v) => ($v === null || $v === '') ? null : (float) $v;
$page = 0;
$n = 0;
$total = null;
$pdo->beginTransaction();
while ($url) {
$res = dpe_fetch_json($url);
$total ??= $res['total'] ?? null;
$rows = $res['results'] ?? [];
if (!$rows) {
break;
}
foreach ($rows as $r) {
$id = $r['numero_dpe'] ?? '';
if ($id === '') {
continue;
}
$lat = $lon = null;
if (!empty($r['_geopoint']) && str_contains($r['_geopoint'], ',')) {
[$lat, $lon] = array_map('floatval', explode(',', $r['_geopoint'], 2));
}
$ins->execute([
substr($id, 0, 20),
$letter($r['etiquette_dpe'] ?? null),
$letter($r['etiquette_ges'] ?? null),
$numOrNull($r['conso_kwhep_m2_an'] ?? null),
$numOrNull($r['emission_ges_kg_co2_m2_an'] ?? null),
isset($r['surface_utile']) && $r['surface_utile'] !== '' ? (int) round((float) $r['surface_utile']) : null,
$r['periode_construction'] ?? null,
$r['categorie_erp'] ?? null,
$date($r['date_etablissement_dpe'] ?? null),
$date($r['date_fin_validite_dpe'] ?? null),
$r['code_insee_ban'] ?? null,
$r['code_postal_ban'] ?? null,
$r['adresse_ban'] ?? null,
$r['nom_commune_ban'] ?? null,
$lat, $lon,
]);
$n++;
}
$pdo->commit();
$pdo->beginTransaction();
$page++;
$log(sprintf('Page %d — %d DPE importés%s', $page, $n, $total ? " / $total" : ''));
// Page suivante (curseur). data-fair renvoie l'URL complète dans « next ».
$url = $res['next'] ?? null;
if ($url) {
usleep(800000); // ménage le quota anonyme de l'API (20 s de traitement / 60 s)
}
}
$pdo->commit();
return $n;
}
/** Crée la table de rattachement « etab_dpe » si absente. */
function dpe_ensure_link_table(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS etab_dpe (
uai VARCHAR(12) NOT NULL,
numero_dpe VARCHAR(20) NOT NULL,
distance_m SMALLINT NOT NULL, -- distance école↔bâtiment (m)
PRIMARY KEY (uai, numero_dpe),
INDEX idx_uai (uai)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
);
}
/**
* Rattache les DPE aux établissements par proximité (même commune, ≤ $radius m).
* @return array{pairs:int, ecoles:int}
*/
function dpe_match(PDO $pdo, int $radius, callable $log): array
{
foreach (['dpe', 'etablissements'] as $t) {
if (!$pdo->query('SHOW TABLES LIKE ' . $pdo->quote($t))->fetchColumn()) {
throw new RuntimeException("Table « $t » absente — lancez d'abord l'import.");
}
}
if (!$pdo->query("SHOW COLUMNS FROM etablissements LIKE 'comc'")->fetchColumn()) {
throw new RuntimeException("Colonne « comc » absente — lancez d'abord import_comc.php.");
}
dpe_ensure_link_table($pdo);
$log("Rattachement (rayon = {$radius} m)…");
$pdo->exec('TRUNCATE TABLE etab_dpe');
// Jointure restreinte à la commune (index idx_insee / idx_comc) puis filtre
// par distance sphérique (ST_Distance_Sphere ⇒ MySQL 5.7.6+ / MariaDB 10.2.4+).
$sql =
'INSERT INTO etab_dpe (uai, numero_dpe, distance_m)
SELECT e.id, d.numero_dpe,
ROUND(ST_Distance_Sphere(POINT(e.lon, e.lat), POINT(d.lon, d.lat)))
FROM etablissements e
JOIN dpe d ON d.code_insee = e.comc
WHERE e.comc IS NOT NULL AND e.lat IS NOT NULL AND e.lon IS NOT NULL
AND d.lat IS NOT NULL AND d.lon IS NOT NULL
AND ST_Distance_Sphere(POINT(e.lon, e.lat), POINT(d.lon, d.lat)) <= ?';
$st = $pdo->prepare($sql);
$st->execute([$radius]);
return [
'pairs' => (int) $pdo->query('SELECT COUNT(*) FROM etab_dpe')->fetchColumn(),
'ecoles' => (int) $pdo->query('SELECT COUNT(DISTINCT uai) FROM etab_dpe')->fetchColumn(),
];
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* Historique des effectifs et nombres de classes d'une école (1er degré).
* GET ?uai=UAI → [ { annee, eleves, classes }, ... ] trié par année croissante
* GET ?action=commune&uai=UAI → [ { annee, eleves }, ... ] effectifs cumulés de la commune
* de l'établissement (somme de toutes ses écoles par année)
* GET ?action=latest (POST) → { uai: eleves } dernier effectif (batch)
*
* Source : dataset « fr-en-ecoles-effectifs-nb_classes » (cf. import_effectifs.php).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
// Effectifs cumulés de la commune : somme des élèves des écoles PUBLIQUES
// de la même commune (com + depc), par année.
if (($_GET['action'] ?? '') === 'commune') {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$e = db()->prepare('SELECT com, depc FROM etablissements WHERE id = ?');
$e->execute([$uai]);
$loc = $e->fetch();
if (!$loc || $loc['com'] === null || $loc['depc'] === null) {
json_out([]);
}
$st = db()->prepare(
"SELECT ef.annee, SUM(ef.eleves) AS eleves
FROM effectifs ef
JOIN etablissements e ON e.id = ef.uai
WHERE e.com = ? AND e.depc = ? AND e.stat = 'Public'
GROUP BY ef.annee
ORDER BY ef.annee"
);
$st->execute([$loc['com'], $loc['depc']]);
$out = [];
foreach ($st as $r) {
$out[] = ['annee' => (int) $r['annee'], 'eleves' => (int) $r['eleves']];
}
json_out($out);
}
// Batch : dernier effectif connu pour une liste d'UAI → { uai: eleves }.
if (($_GET['action'] ?? '') === 'latest') {
$uais = body_json()['uais'] ?? [];
if (!is_array($uais) || !$uais) {
json_out([]);
}
$uais = array_slice(array_values(array_unique(array_map('strval', $uais))), 0, 5000);
$ph = implode(',', array_fill(0, count($uais), '?'));
$sql = "SELECT ef.uai, ef.eleves
FROM effectifs ef
JOIN (SELECT uai, MAX(annee) AS y FROM effectifs WHERE uai IN ($ph) GROUP BY uai) m
ON m.uai = ef.uai AND m.y = ef.annee";
$st = db()->prepare($sql);
$st->execute($uais);
$out = [];
foreach ($st as $r) {
$out[$r['uai']] = (int) $r['eleves'];
}
json_out($out);
}
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$st = db()->prepare('SELECT annee, eleves, classes FROM effectifs WHERE uai = ? ORDER BY annee');
$st->execute([$uai]);
$out = [];
foreach ($st as $r) {
$out[] = [
'annee' => (int) $r['annee'],
'eleves' => (int) $r['eleves'],
'classes' => (int) $r['classes'],
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* GET api/etablissements.php
* Renvoie tous les établissements au format attendu par la carte (clés courtes).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$cols = 'id,nom,type,stat,reg,aca,dep,depc,com,cp,adr,tel,mail,web,etat,circ,lat,lon,rest,heb,ulis,segp';
$stmt = db()->query("SELECT $cols FROM etablissements");
$rows = [];
while ($r = $stmt->fetch()) {
$r['lat'] = $r['lat'] !== null ? (float) $r['lat'] : null;
$r['lon'] = $r['lon'] !== null ? (float) $r['lon'] : null;
foreach (['rest', 'heb', 'ulis', 'segp'] as $k) {
$r[$k] = (int) $r[$k];
}
$rows[] = $r;
}
json_out($rows);
} catch (Throwable $e) {
json_error('Base de données : ' . $e->getMessage(), 500);
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* Arborescence globale de dossiers (gérée par le super-admin).
* GET → liste à plat de tous les dossiers (public)
* POST {name, parent_id?} → crée un dossier (super-admin)
* PUT {id, name?, parent_id?, position?} → modifie (super-admin)
* DELETE ?id=N → supprime (et ses sous-dossiers) (super-admin)
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$rows = db()->query('SELECT id, parent_id, name, position FROM folders ORDER BY position, name')->fetchAll();
foreach ($rows as &$r) {
$r['id'] = (int) $r['id'];
$r['parent_id'] = $r['parent_id'] !== null ? (int) $r['parent_id'] : null;
$r['position'] = (int) $r['position'];
}
json_out($rows);
}
require_super();
if ($method === 'POST') {
$b = body_json();
$name = trim((string) ($b['name'] ?? ''));
if ($name === '') {
json_error('Nom de dossier requis.');
}
$parent = isset($b['parent_id']) && $b['parent_id'] !== null && $b['parent_id'] !== ''
? (int) $b['parent_id'] : null;
$pos = (int) ($b['position'] ?? 0);
$st = db()->prepare('INSERT INTO folders (parent_id, name, position) VALUES (?, ?, ?)');
$st->execute([$parent, $name, $pos]);
json_out(['id' => (int) db()->lastInsertId(), 'parent_id' => $parent, 'name' => $name, 'position' => $pos], 201);
}
if ($method === 'PUT') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$sets = [];
$args = [];
if (array_key_exists('name', $b)) {
$name = trim((string) $b['name']);
if ($name === '') {
json_error('Nom invalide.');
}
$sets[] = 'name = ?';
$args[] = $name;
}
if (array_key_exists('parent_id', $b)) {
$parent = ($b['parent_id'] === null || $b['parent_id'] === '') ? null : (int) $b['parent_id'];
if ($parent === $id) {
json_error('Un dossier ne peut pas être son propre parent.');
}
$sets[] = 'parent_id = ?';
$args[] = $parent;
}
if (array_key_exists('position', $b)) {
$sets[] = 'position = ?';
$args[] = (int) $b['position'];
}
if (!$sets) {
json_error('Rien à modifier.');
}
$args[] = $id;
db()->prepare('UPDATE folders SET ' . implode(', ', $sets) . ' WHERE id = ?')->execute($args);
json_out(['ok' => true]);
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM folders WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* Gestion des groupes d'utilisateurs (super-admin uniquement).
*
* Un groupe rassemble des utilisateurs. S'il est « valideur » (can_validate=1),
* ses membres peuvent valider les documents des établissements de ses communes
* (group_scopes). Les commentaires de fiche sont visibles entre membres d'un
* même groupe (voir api/comments.php).
*
* GET → liste des groupes (membres + communes)
* POST {name, can_validate?} → crée un groupe
* POST ?action=toggle {id, can_validate} → (dés)active le droit de validation
* POST ?action=member {group_id, user_id} → ajoute un membre
* POST ?action=scope {group_id, com, depc}→ ajoute une commune au périmètre
* DELETE ?id=N → supprime un groupe
* DELETE ?action=member&group_id=G&user_id=U → retire un membre
* DELETE ?action=scope&id=N → retire une commune
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
require_super();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// ---- Liste des groupes (avec membres et communes) ---------------------
if ($method === 'GET') {
$groups = db()->query('SELECT id, name, can_validate, created_at FROM user_groups ORDER BY name')->fetchAll();
$members = db()->query(
'SELECT gm.group_id, u.id, u.username
FROM group_members gm JOIN users u ON u.id = gm.user_id
ORDER BY u.username'
)->fetchAll();
$scopes = db()->query('SELECT id, group_id, com, depc FROM group_scopes ORDER BY com')->fetchAll();
$byMembers = [];
foreach ($members as $m) {
$byMembers[(int) $m['group_id']][] = ['id' => (int) $m['id'], 'username' => $m['username']];
}
$byScopes = [];
foreach ($scopes as $s) {
$byScopes[(int) $s['group_id']][] = ['id' => (int) $s['id'], 'com' => $s['com'], 'depc' => $s['depc']];
}
foreach ($groups as &$g) {
$g['id'] = (int) $g['id'];
$g['can_validate'] = (int) $g['can_validate'];
$g['members'] = $byMembers[$g['id']] ?? [];
$g['scopes'] = $byScopes[$g['id']] ?? [];
}
json_out($groups);
}
// ---- Bascule du droit de validation -----------------------------------
if ($method === 'POST' && $action === 'toggle') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
$can = !empty($b['can_validate']) ? 1 : 0;
db()->prepare('UPDATE user_groups SET can_validate = ? WHERE id = ?')->execute([$can, $id]);
json_out(['ok' => true, 'can_validate' => $can]);
}
// ---- Ajout d'un membre ------------------------------------------------
if ($method === 'POST' && $action === 'member') {
$b = body_json();
$gid = (int) ($b['group_id'] ?? 0);
$uid = (int) ($b['user_id'] ?? 0);
if ($gid <= 0 || $uid <= 0) {
json_error('group_id et user_id requis.');
}
db()->prepare('INSERT IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
->execute([$gid, $uid]);
json_out(['ok' => true]);
}
// ---- Ajout d'une commune au périmètre ---------------------------------
if ($method === 'POST' && $action === 'scope') {
$b = body_json();
$gid = (int) ($b['group_id'] ?? 0);
$com = trim((string) ($b['com'] ?? ''));
$depc = trim((string) ($b['depc'] ?? ''));
if ($gid <= 0 || $com === '' || $depc === '') {
json_error('group_id, com et depc requis.');
}
db()->prepare('INSERT IGNORE INTO group_scopes (group_id, com, depc) VALUES (?, ?, ?)')
->execute([$gid, $com, $depc]);
json_out(['ok' => true]);
}
// ---- Création d'un groupe ---------------------------------------------
if ($method === 'POST') {
$b = body_json();
$name = trim((string) ($b['name'] ?? ''));
$can = !empty($b['can_validate']) ? 1 : 0;
if ($name === '') {
json_error('Nom de groupe requis.');
}
$exists = db()->prepare('SELECT 1 FROM user_groups WHERE name = ?');
$exists->execute([$name]);
if ($exists->fetchColumn()) {
json_error('Ce nom de groupe existe déjà.');
}
db()->prepare('INSERT INTO user_groups (name, can_validate) VALUES (?, ?)')->execute([$name, $can]);
json_out(['id' => (int) db()->lastInsertId(), 'name' => $name, 'can_validate' => $can], 201);
}
// ---- Retrait d'un membre ----------------------------------------------
if ($method === 'DELETE' && $action === 'member') {
$gid = (int) ($_GET['group_id'] ?? 0);
$uid = (int) ($_GET['user_id'] ?? 0);
if ($gid <= 0 || $uid <= 0) {
json_error('group_id et user_id requis.');
}
db()->prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?')->execute([$gid, $uid]);
json_out(['ok' => true]);
}
// ---- Retrait d'une commune --------------------------------------------
if ($method === 'DELETE' && $action === 'scope') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM group_scopes WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
// ---- Suppression d'un groupe ------------------------------------------
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM user_groups WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* Import de la dernière version de l'opendata (super-admin).
* POST → télécharge l'export de l'annuaire et met à jour la base.
*
* Nécessite un accès Internet sortant côté serveur.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
const OPENDATA_URL =
'https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-annuaire-education/exports/json'
. '?select=identifiant_de_l_etablissement,nom_etablissement,type_etablissement,statut_public_prive,'
. 'libelle_region,libelle_academie,libelle_departement,code_departement,nom_commune,code_postal,adresse_1,'
. 'telephone,mail,web,etat,nom_circonscription,restauration,hebergement,ulis,segpa,apprentissage,'
. 'latitude,longitude';
try {
require_super();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_error('Méthode non supportée.', 405);
}
set_time_limit(0);
ini_set('memory_limit', '1024M');
// Téléchargement.
$raw = false;
if (function_exists('curl_init')) {
$ch = curl_init(OPENDATA_URL);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 600,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'ecoles-import/1.0',
]);
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Téléchargement échoué : ' . $err);
}
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
throw new RuntimeException("Réponse HTTP $code de l'opendata.");
}
} else {
$raw = @file_get_contents(OPENDATA_URL);
if ($raw === false) {
throw new RuntimeException('Téléchargement échoué (file_get_contents).');
}
}
$rows = json_decode($raw, true);
unset($raw);
if (!is_array($rows)) {
throw new RuntimeException('Réponse opendata illisible.');
}
// Transformation (clés courtes, géoloc obligatoire).
$clean = [];
$skipped = 0;
foreach ($rows as $r) {
$rec = map_opendata_row($r);
if ($rec === null || empty($rec['id'])) {
$skipped++;
continue;
}
$clean[] = $rec;
}
unset($rows);
$n = import_etablissements(db(), $clean);
$total = (int) db()->query('SELECT COUNT(*) FROM etablissements')->fetchColumn();
json_out([
'ok' => true,
'imported' => $n,
'skipped' => $skipped,
'total' => $total,
]);
} catch (Throwable $e) {
json_error('Import : ' . $e->getMessage(), 500);
}
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* Import des DPE ADEME + rattachement aux établissements (super-admin).
* POST body { dep?: "75", radius?: 80 }
* → télécharge le dataset DPE tertiaire, remplit « dpe », puis rattache
* les DPE aux écoles par proximité (table « etab_dpe »).
*
* ⚠ Opération longue (le dataset complet ≈ 532 000 DPE) : à réserver à un
* import ponctuel. Pour de gros volumes, préférez les scripts CLI
* (php import_dpe.php ; php match_dpe.php). Accès Internet requis côté serveur.
*/
declare(strict_types=1);
require __DIR__ . '/dpe_lib.php';
try {
require_super();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_error('Méthode non supportée.', 405);
}
set_time_limit(0);
ini_set('memory_limit', '512M');
$in = body_json();
$dep = isset($in['dep']) && $in['dep'] !== '' ? preg_replace('/[^0-9AB]/', '', strtoupper((string) $in['dep'])) : null;
$radius = isset($in['radius']) ? max(1, min(2000, (int) $in['radius'])) : 5;
$pdo = db();
$imported = dpe_import($pdo, $dep, static fn(string $m) => null);
$geo = (int) $pdo->query('SELECT COUNT(*) FROM dpe WHERE lat IS NOT NULL')->fetchColumn();
$match = dpe_match($pdo, $radius, static fn(string $m) => null);
json_out([
'ok' => true,
'imported' => $imported,
'geocoded' => $geo,
'radius' => $radius,
'links' => $match['pairs'],
'etabs' => $match['ecoles'],
'dep' => $dep,
]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
json_error('Import DPE : ' . $e->getMessage(), 500);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Historique de l'IPS (indice de position sociale) d'une école (1er degré).
* GET ?uai=UAI → [ { annee, ips, ips_dep }, ... ] trié par année croissante
* ips = IPS de l'établissement
* ips_dep = IPS moyen départemental (référence), ou null
*
* Source : dataset « fr-en-ips-ecoles-ap2022 » (cf. import_ips.php).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
// La table peut être absente si l'import n'a pas encore été lancé.
if (!db()->query("SHOW TABLES LIKE 'ips'")->fetchColumn()) {
json_out([]);
}
$st = db()->prepare('SELECT annee, ips, ips_dep FROM ips WHERE uai = ? ORDER BY annee');
$st->execute([$uai]);
$out = [];
foreach ($st as $r) {
$out[] = [
'annee' => (int) $r['annee'],
'ips' => (float) $r['ips'],
'ips_dep' => $r['ips_dep'] !== null ? (float) $r['ips_dep'] : null,
];
}
json_out($out);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* Population des zones IRIS (recensement INSEE 2022).
*
* GET ?code=<code_iris>&com=<code_commune>
* → { iris_pop, com_pop, com_name } pour un IRIS et sa commune
* POST ?action=batch body {codes:[...]}
* → { "<code_iris>": pop, ... } pour colorer la couche
*
* Population communale = somme des IRIS de la commune. Pour Paris, Lyon et
* Marseille, les données sont à l'arrondissement : on agrège à l'échelle de
* la VILLE entière (com_name renvoie alors « Paris » / « Lyon » / « Marseille »).
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
/** Ville PLM correspondant à un code commune-arrondissement, ou null. */
function plm_city(string $com): ?array
{
if ($com >= '75101' && $com <= '75120') return ['Paris', '75101', '75120'];
if ($com >= '69381' && $com <= '69389') return ['Lyon', '69381', '69389'];
if ($com >= '13201' && $com <= '13216') return ['Marseille', '13201', '13216'];
return null;
}
try {
// --- Batch : populations d'une liste d'IRIS (pour la coloration) ---
if (($_GET['action'] ?? '') === 'batch') {
$codes = body_json()['codes'] ?? [];
if (!is_array($codes) || !$codes) {
json_out([]);
}
$codes = array_slice(array_values(array_unique(array_map('strval', $codes))), 0, 5000);
$ph = implode(',', array_fill(0, count($codes), '?'));
$st = db()->prepare("SELECT code, pop FROM iris_pop WHERE code IN ($ph)");
$st->execute($codes);
$out = [];
foreach ($st as $r) {
$out[$r['code']] = (float) $r['pop'];
}
json_out($out);
}
// --- Détail d'un IRIS + sa commune ---
$code = trim((string) ($_GET['code'] ?? ''));
$com = trim((string) ($_GET['com'] ?? ''));
$irisPop = null;
if ($code !== '') {
$st = db()->prepare('SELECT pop FROM iris_pop WHERE code = ?');
$st->execute([$code]);
$v = $st->fetchColumn();
if ($v !== false) {
$irisPop = (int) round((float) $v);
}
}
$comPop = null;
$comName = null;
if ($com !== '') {
$plm = plm_city($com);
if ($plm) {
$comName = $plm[0];
$st = db()->prepare('SELECT SUM(pop) FROM iris_pop WHERE com BETWEEN ? AND ?');
$st->execute([$plm[1], $plm[2]]);
} else {
$st = db()->prepare('SELECT SUM(pop) FROM iris_pop WHERE com = ?');
$st->execute([$com]);
}
$v = $st->fetchColumn();
if ($v !== null && $v !== false) {
$comPop = (int) round((float) $v);
}
}
json_out(['iris_pop' => $irisPop, 'com_pop' => $comPop, 'com_name' => $comName]);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+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);
}
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* Historique de la population municipale (INSEE) de la zone d'un établissement.
* GET ?uai=UAI → { zone: [{annee,pop},…], zone_estimated: bool, ville: [...]|null, ville_nom }
*
* « zone » = commune, ou arrondissement pour Paris/Lyon/Marseille (code INSEE comc de
* l'établissement). Pour un arrondissement, « ville » donne en plus la courbe historique
* de la ville entière. Sinon « ville » est null.
*
* Lyon/Marseille n'ont pas d'historique INSEE par arrondissement : « zone » est alors
* ESTIMÉE (zone_estimated=true) = population de la ville × part de l'arrondissement dans
* les IRIS du recensement 2022 (iris_pop).
*
* Source : pop_commune (cf. import_pop_hist.php), prolongée à 2022 via iris_pop.
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
/**
* Ville entière pour un arrondissement Paris/Lyon/Marseille :
* [ codes pop_commune (historique), codes iris_pop (point 2022), nom ] — ou null.
* Paris n'a pas de code commune unique dans pop_commune : on somme les arrondissements.
*/
function plm_ville(string $comc): ?array
{
$i = (int) $comc;
$codes = static fn(int $a, int $b) => array_map('strval', range($a, $b));
if ($i >= 13201 && $i <= 13216) return [['13055'], $codes(13201, 13216), 'Marseille'];
if ($i >= 69381 && $i <= 69389) return [['69123'], $codes(69381, 69389), 'Lyon'];
if ($i >= 75101 && $i <= 75120) return [$codes(75101, 75120), $codes(75101, 75120), 'Paris'];
return null;
}
/** Somme des populations IRIS (recensement 2022) pour une liste de codes commune/arrondissement. */
function iris_sum(array $codes): float
{
if (!$codes) {
return 0.0;
}
$ph = implode(',', array_fill(0, count($codes), '?'));
$st = db()->prepare("SELECT SUM(pop) FROM iris_pop WHERE com IN ($ph)");
$st->execute(array_map('strval', $codes));
return (float) $st->fetchColumn();
}
/**
* Construit une série [{annee,pop}] : somme de pop_commune sur $histCodes (par année),
* prolongée à 2022 par la somme d'iris_pop sur $irisCodes.
*/
function pop_series(bool $hasHist, bool $hasIris, array $histCodes, array $irisCodes): array
{
$out = [];
$hasYear = [];
if ($hasHist && $histCodes) {
$ph = implode(',', array_fill(0, count($histCodes), '?'));
$st = db()->prepare("SELECT annee, SUM(pop) AS pop FROM pop_commune WHERE comc IN ($ph) GROUP BY annee ORDER BY annee");
$st->execute($histCodes);
foreach ($st as $r) {
$y = (int) $r['annee'];
$out[] = ['annee' => $y, 'pop' => (int) $r['pop']];
$hasYear[$y] = true;
}
}
if ($hasIris && $irisCodes && empty($hasYear[2022])) {
$ph = implode(',', array_fill(0, count($irisCodes), '?'));
$p = db()->prepare("SELECT ROUND(SUM(pop)) FROM iris_pop WHERE com IN ($ph)");
$p->execute($irisCodes);
$v = $p->fetchColumn();
if ($v !== null && $v !== false && (float) $v > 0) {
$out[] = ['annee' => 2022, 'pop' => (int) $v];
}
}
usort($out, static fn($a, $b) => $a['annee'] - $b['annee']);
return $out;
}
try {
$uai = trim((string) ($_GET['uai'] ?? ''));
if ($uai === '') {
json_error('Paramètre uai requis.');
}
$hasHist = (bool) db()->query("SHOW TABLES LIKE 'pop_commune'")->fetchColumn();
$hasIris = (bool) db()->query("SHOW TABLES LIKE 'iris_pop'")->fetchColumn();
if (!$hasHist && !$hasIris) {
json_out(['zone' => [], 'ville' => null, 'ville_nom' => null]);
}
$e = db()->prepare('SELECT comc FROM etablissements WHERE id = ?');
$e->execute([$uai]);
$comc = $e->fetchColumn();
if (!$comc) {
json_out(['zone' => [], 'ville' => null, 'ville_nom' => null]);
}
$comc = (string) $comc;
// Zone = commune ou arrondissement (code de l'établissement directement).
$zone = pop_series($hasHist, $hasIris, [$comc], [$comc]);
// Ville entière (seulement pour un arrondissement PLM).
$ville = null;
$villeNom = null;
$estimated = false;
if ($plm = plm_ville($comc)) {
[$histCodes, $irisCodes, $villeNom] = $plm;
$ville = pop_series($hasHist, $hasIris, $histCodes, $irisCodes);
// Si l'arrondissement n'a pas d'historique propre (Lyon/Marseille), on l'ESTIME :
// population de la ville × part de l'arrondissement dans les IRIS (recensement 2022).
if (count($zone) < 2 && $ville && $hasIris) {
$arrIris = iris_sum([$comc]);
$cityIris = iris_sum($irisCodes);
if ($arrIris > 0 && $cityIris > 0) {
$share = $arrIris / $cityIris;
$zone = array_map(
static fn(array $p) => ['annee' => $p['annee'], 'pop' => (int) round($p['pop'] * $share)],
$ville
);
$estimated = true;
}
}
}
json_out([
'zone' => $zone,
'zone_estimated' => $estimated,
'ville' => $ville,
'ville_nom' => $villeNom,
]);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* Gestion des administrateurs (super-admin uniquement).
* GET → liste des utilisateurs + périmètres + email
* POST {username, password, role?, email?} → crée un utilisateur (role « perimeter »
* par défaut ; « member » = simple commentateur ;
* mot de passe soumis à la politique, must_change=1)
* POST ?action=password {id, password}→ réinitialise un mot de passe (politique, must_change)
* POST ?action=email {id, email} → met à jour l'e-mail d'un compte
* POST ?action=scope {user_id, com, depc} → ajoute une commune au périmètre
* DELETE ?id=N → supprime un admin
* DELETE ?action=scope&id=N → retire une commune d'un périmètre
*/
declare(strict_types=1);
require __DIR__ . '/db.php';
try {
$me = require_super();
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
if ($method === 'GET') {
$users = db()->query('SELECT id, username, role, email, must_change, created_at FROM users ORDER BY role DESC, username')->fetchAll();
$scopes = db()->query('SELECT id, user_id, com, depc FROM user_scopes ORDER BY com')->fetchAll();
$byUser = [];
foreach ($scopes as $s) {
$byUser[(int) $s['user_id']][] = ['id' => (int) $s['id'], 'com' => $s['com'], 'depc' => $s['depc']];
}
foreach ($users as &$u) {
$u['id'] = (int) $u['id'];
$u['must_change'] = (int) $u['must_change'];
$u['scopes'] = $byUser[$u['id']] ?? [];
}
json_out($users);
}
// Mise à jour de l'e-mail d'un compte.
if ($method === 'POST' && $action === 'email') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
$email = trim((string) ($b['email'] ?? ''));
if ($id <= 0) {
json_error('id requis.');
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_error('Adresse e-mail invalide.');
}
db()->prepare('UPDATE users SET email = ? WHERE id = ?')->execute([$email !== '' ? $email : null, $id]);
json_out(['ok' => true]);
}
if ($method === 'POST' && $action === 'scope') {
$b = body_json();
$uid = (int) ($b['user_id'] ?? 0);
$com = trim((string) ($b['com'] ?? ''));
$depc = trim((string) ($b['depc'] ?? ''));
if ($uid <= 0 || $com === '' || $depc === '') {
json_error('user_id, com et depc requis.');
}
db()->prepare('INSERT IGNORE INTO user_scopes (user_id, com, depc) VALUES (?, ?, ?)')
->execute([$uid, $com, $depc]);
json_out(['ok' => true]);
}
if ($method === 'POST' && $action === 'password') {
$b = body_json();
$id = (int) ($b['id'] ?? 0);
$pwd = (string) ($b['password'] ?? '');
if ($id <= 0) {
json_error('id requis.');
}
if ($err = password_policy_check($pwd)) {
json_error($err);
}
// Mot de passe posé par l'admin → l'utilisateur devra le changer à sa connexion.
db()->prepare('UPDATE users SET password_hash = ?, must_change = 1, failed_attempts = 0, locked_until = NULL WHERE id = ?')
->execute([password_hash($pwd, PASSWORD_DEFAULT), $id]);
json_out(['ok' => true]);
}
if ($method === 'POST') {
$b = body_json();
$username = trim((string) ($b['username'] ?? ''));
$pwd = (string) ($b['password'] ?? '');
$email = trim((string) ($b['email'] ?? ''));
$roleIn = (string) ($b['role'] ?? 'perimeter');
$role = in_array($roleIn, ['super', 'perimeter', 'member'], true) ? $roleIn : 'perimeter';
if ($username === '') {
json_error('Identifiant requis.');
}
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_error('Adresse e-mail invalide.');
}
if ($err = password_policy_check($pwd)) {
json_error($err);
}
$exists = db()->prepare('SELECT 1 FROM users WHERE username = ?');
$exists->execute([$username]);
if ($exists->fetchColumn()) {
json_error('Cet identifiant existe déjà.');
}
// Mot de passe initial posé par l'admin → changement forcé à la 1re connexion.
db()->prepare('INSERT INTO users (username, password_hash, role, email, must_change) VALUES (?, ?, ?, ?, 1)')
->execute([$username, password_hash($pwd, PASSWORD_DEFAULT), $role, $email !== '' ? $email : null]);
json_out(['id' => (int) db()->lastInsertId(), 'username' => $username, 'role' => $role], 201);
}
if ($method === 'DELETE' && $action === 'scope') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
db()->prepare('DELETE FROM user_scopes WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
if ($method === 'DELETE') {
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
json_error('id requis.');
}
if ($id === (int) $me['id']) {
json_error('Vous ne pouvez pas supprimer votre propre compte.');
}
db()->prepare('DELETE FROM users WHERE id = ?')->execute([$id]);
json_out(['deleted' => $id]);
}
json_error('Méthode non supportée.', 405);
} catch (Throwable $e) {
json_error('Erreur : ' . $e->getMessage(), 500);
}