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:
+153
@@ -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 d’essais. 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 l’actuel.');
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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é à l’administrateur 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
@@ -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
@@ -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(),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 n’est 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 l’enregistrement 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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user