diff --git a/docs/import-stats.md b/docs/import-stats.md new file mode 100644 index 0000000..d246b55 --- /dev/null +++ b/docs/import-stats.md @@ -0,0 +1,124 @@ +# Import d'objets Stats + +Cette fonctionnalité permet d'importer des objets Stats à partir d'un fichier JSON via l'interface d'administration. + +## Accès + +La page d'import est accessible via : +- L'URL : `/admin/import-stats` +- Le menu de navigation : "Import Stats" + +## Fonctionnalités + +### Sécurité +- **Aucune modification** des objets Stats existants +- Seuls les nouveaux objets sont créés +- Vérification de l'existence par le code INSEE (`zone`) + +### Validation +- Vérification du format JSON +- Validation des champs requis (`zone` et `name`) +- Gestion des erreurs par ligne +- Rapport détaillé des résultats + +### Champs supportés + +#### Champs requis +- `zone` : Code INSEE de la zone (ex: "75056") +- `name` : Nom de la ville/zone (ex: "Paris") + +#### Champs optionnels +- `population` : Population de la zone (nombre) +- `budgetAnnuel` : Budget annuel de la collectivité (chaîne) +- `siren` : Code SIREN (nombre) +- `codeEpci` : Code EPCI (nombre) +- `codesPostaux` : Codes postaux séparés par des points-virgules (ex: "75001;75002;75003") + +#### Objet `decomptes` (optionnel) +- `placesCount` : Nombre total de lieux +- `avecHoraires` : Nombre de lieux avec horaires +- `avecAdresse` : Nombre de lieux avec adresse +- `avecSite` : Nombre de lieux avec site web +- `avecAccessibilite` : Nombre de lieux avec accessibilité +- `avecNote` : Nombre de lieux avec note +- `completionPercent` : Pourcentage de complétion + +## Format JSON attendu + +```json +[ + { + "zone": "75056", + "name": "Paris", + "population": 2161000, + "budgetAnnuel": "8500000000", + "siren": 200054781, + "codeEpci": 200054781, + "codesPostaux": "75001;75002;75003", + "decomptes": { + "placesCount": 1250, + "avecHoraires": 980, + "avecAdresse": 1200, + "avecSite": 850, + "avecAccessibilite": 450, + "avecNote": 320, + "completionPercent": 75 + } + } +] +``` + +## Utilisation + +1. **Préparer le fichier JSON** + - Créer un tableau d'objets Stats + - Inclure au minimum les champs `zone` et `name` + - Valider le format JSON + +2. **Accéder à la page d'import** + - Aller sur `/admin/import-stats` + - Ou cliquer sur "Import Stats" dans le menu + +3. **Importer le fichier** + - Sélectionner le fichier JSON + - Cliquer sur "Importer" + - Vérifier les messages de résultat + +4. **Vérifier les résultats** + - Nombre d'objets créés + - Nombre d'objets ignorés (déjà existants) + - Liste des erreurs éventuelles + +## Messages de retour + +### Succès +``` +Import terminé : X objet(s) créé(s), Y objet(s) ignoré(s) (déjà existants). +``` + +### Erreurs possibles +- "Aucun fichier JSON n'a été fourni." +- "Le fichier doit être au format JSON." +- "Erreur lors du décodage JSON: [message]" +- "Le fichier JSON doit contenir un tableau d'objets Stats." +- "Ligne X: Champs 'zone' et 'name' requis" +- "Ligne X: [message d'erreur spécifique]" + +## Logs + +Toutes les actions d'import sont loggées via le service `ActionLogger` : +- `admin/import_stats` : Accès à la page +- `admin/import_stats_success` : Import réussi avec statistiques +- `admin/import_stats_error` : Erreur lors de l'import + +## Exemple de fichier de test + +Un fichier `test_import_stats.json` est fourni avec des exemples pour Paris, Lyon et Marseille. + +## Notes importantes + +- Les objets existants ne sont jamais modifiés +- Seuls les nouveaux objets sont créés +- Les dates de création et modification sont automatiquement définies +- Les erreurs sont affichées par ligne pour faciliter le débogage +- Le système est conçu pour être sûr et non destructif \ No newline at end of file diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 3747c23..53d7bb8 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -1320,60 +1320,306 @@ final class AdminController extends AbstractController #[Route('/admin/podium-contributeurs-osm', name: 'app_admin_podium_contributeurs_osm')] public function podiumContributeursOsm(): Response { - // Ajout d'un log d'action avec le service ActionLogger - $this->actionLogger->log('podium_contributeurs_osm', []); - // On suppose que le champ "osmUser" existe sur l'entité Place - $placeRepo = $this->entityManager->getRepository(\App\Entity\Place::class); - - // Nouvelle requête groupée pour tout calculer d'un coup - $qb = $placeRepo->createQueryBuilder('p') - ->select( - 'p.osm_user', - 'COUNT(p.id) as nb', - 'AVG((CASE WHEN p.has_opening_hours = true THEN 1 ELSE 0 END) +' - . ' (CASE WHEN p.has_address = true THEN 1 ELSE 0 END) +' - . ' (CASE WHEN p.has_website = true THEN 1 ELSE 0 END) +' - . ' (CASE WHEN p.has_wheelchair = true THEN 1 ELSE 0 END) +' - . ' (CASE WHEN p.has_note = true THEN 1 ELSE 0 END)) / 5 * 100 as completion_moyen' - ) - ->where('p.osm_user IS NOT NULL') - ->andWhere("p.osm_user != ''") - ->groupBy('p.osm_user') - ->orderBy('nb', 'DESC') - ->setMaxResults(100); - - $podium = $qb->getQuery()->getResult(); - - // Calcul du score pondéré et normalisation - $maxPondere = 0; - foreach ($podium as &$row) { - $row['completion_moyen'] = $row['completion_moyen'] !== null ? round($row['completion_moyen'], 1) : null; - $row['completion_pondere'] = ($row['completion_moyen'] !== null && $row['nb'] > 0) - ? round($row['completion_moyen'] * $row['nb'], 1) - : null; - if ($row['completion_pondere'] !== null && $row['completion_pondere'] > $maxPondere) { - $maxPondere = $row['completion_pondere']; + $this->actionLogger->log('admin/podium_contributeurs_osm', []); + + // Récupérer tous les lieux avec un utilisateur OSM + $places = $this->entityManager->getRepository(Place::class)->findBy(['osm_user' => null], ['osm_user' => 'ASC']); + $places = array_filter($places, fn($place) => $place->getOsmUser() !== null); + + // Compter les contributions par utilisateur + $contributions = []; + foreach ($places as $place) { + $user = $place->getOsmUser(); + if ($user) { + if (!isset($contributions[$user])) { + $contributions[$user] = 0; + } + $contributions[$user]++; } } - unset($row); - // Normalisation des scores pondérés entre 0 et 100 - foreach ($podium as &$row) { - if ($maxPondere > 0 && $row['completion_pondere'] !== null) { - $row['completion_pondere_normalisee'] = round($row['completion_pondere'] / $maxPondere * 100, 1); - } else { - $row['completion_pondere_normalisee'] = null; - } - } - unset($row); + // Trier par nombre de contributions décroissant + arsort($contributions); + + // Prendre les 10 premiers + $topContributors = array_slice($contributions, 0, 10, true); - // Tri décroissant sur le score normalisé - usort($podium, function ($a, $b) { - return ($b['completion_pondere_normalisee'] ?? 0) <=> ($a['completion_pondere_normalisee'] ?? 0); - }); - return $this->render('admin/podium_contributeurs_osm.html.twig', [ - 'podium' => $podium + 'contributors' => $topContributors ]); } + + #[Route('/admin/import-stats', name: 'app_admin_import_stats', methods: ['GET', 'POST'])] + public function importStats(Request $request): Response + { + $this->actionLogger->log('admin/import_stats', []); + + if ($request->isMethod('POST')) { + $uploadedFile = $request->files->get('json_file'); + + if (!$uploadedFile) { + $this->addFlash('error', 'Aucun fichier JSON n\'a été fourni.'); + return $this->redirectToRoute('app_admin_import_stats'); + } + + // Vérifier le type de fichier + if ($uploadedFile->getClientMimeType() !== 'application/json' && + $uploadedFile->getClientOriginalExtension() !== 'json') { + $this->addFlash('error', 'Le fichier doit être au format JSON.'); + return $this->redirectToRoute('app_admin_import_stats'); + } + + try { + // Lire le contenu du fichier + $jsonContent = file_get_contents($uploadedFile->getPathname()); + $data = json_decode($jsonContent, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception('Erreur lors du décodage JSON: ' . json_last_error_msg()); + } + + if (!is_array($data)) { + throw new \Exception('Le fichier JSON doit contenir un tableau d\'objets Stats.'); + } + + $createdCount = 0; + $skippedCount = 0; + $errors = []; + + foreach ($data as $index => $statData) { + try { + // Vérifier que les champs requis sont présents + if (!isset($statData['zone']) || !isset($statData['name'])) { + $errors[] = "Ligne " . ($index + 1) . ": Champs 'zone' et 'name' requis"; + continue; + } + + $zone = $statData['zone']; + $name = $statData['name']; + + // Vérifier si l'objet Stats existe déjà + $existingStats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zone]); + + if ($existingStats) { + $skippedCount++; + continue; // Ignorer les objets existants + } + + // Créer un nouvel objet Stats + $stats = new Stats(); + $stats->setZone($zone) + ->setName($name) + ->setDateCreated(new \DateTime()) + ->setDateModified(new \DateTime()); + + // Remplir les champs optionnels + if (isset($statData['population'])) { + $stats->setPopulation($statData['population']); + } + if (isset($statData['budgetAnnuel'])) { + $stats->setBudgetAnnuel($statData['budgetAnnuel']); + } + if (isset($statData['siren'])) { + $stats->setSiren($statData['siren']); + } + if (isset($statData['codeEpci'])) { + $stats->setCodeEpci($statData['codeEpci']); + } + if (isset($statData['codesPostaux'])) { + $stats->setCodesPostaux($statData['codesPostaux']); + } + + // Remplir les décomptes si disponibles + if (isset($statData['decomptes'])) { + $decomptes = $statData['decomptes']; + if (isset($decomptes['placesCount'])) { + $stats->setPlacesCount($decomptes['placesCount']); + } + if (isset($decomptes['avecHoraires'])) { + $stats->setAvecHoraires($decomptes['avecHoraires']); + } + if (isset($decomptes['avecAdresse'])) { + $stats->setAvecAdresse($decomptes['avecAdresse']); + } + if (isset($decomptes['avecSite'])) { + $stats->setAvecSite($decomptes['avecSite']); + } + if (isset($decomptes['avecAccessibilite'])) { + $stats->setAvecAccessibilite($decomptes['avecAccessibilite']); + } + if (isset($decomptes['avecNote'])) { + $stats->setAvecNote($decomptes['avecNote']); + } + if (isset($decomptes['completionPercent'])) { + $stats->setCompletionPercent($decomptes['completionPercent']); + } + } + + $this->entityManager->persist($stats); + $createdCount++; + + } catch (\Exception $e) { + $errors[] = "Ligne " . ($index + 1) . ": " . $e->getMessage(); + } + } + + // Sauvegarder les changements + $this->entityManager->flush(); + + // Afficher les résultats + $message = "Import terminé : $createdCount objet(s) créé(s), $skippedCount objet(s) ignoré(s) (déjà existants)."; + if (!empty($errors)) { + $message .= " Erreurs : " . count($errors); + foreach ($errors as $error) { + $this->addFlash('warning', $error); + } + } + + $this->addFlash('success', $message); + $this->actionLogger->log('admin/import_stats_success', [ + 'created' => $createdCount, + 'skipped' => $skippedCount, + 'errors' => count($errors) + ]); + + } catch (\Exception $e) { + $this->addFlash('error', 'Erreur lors de l\'import : ' . $e->getMessage()); + $this->actionLogger->log('admin/import_stats_error', ['error' => $e->getMessage()]); + } + + return $this->redirectToRoute('app_admin_import_stats'); + } + + return $this->render('admin/import_stats.html.twig'); + } + + #[Route('/admin/export-overpass-csv/{insee_code}', name: 'app_admin_export_overpass_csv')] + public function exportOverpassCsv($insee_code): Response + { + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + + if (!$stats) { + throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE'); + } + + // Construire la requête Overpass + $overpassQuery = '[out:csv(::id,::type,name,amenity,shop,office,craft,leisure,healthcare,emergency,man_made,power,highway,railway,public_transport,landuse,historic,barrier,tourism,sport,place,waterway,natural,geological,route,military,traffic_sign,traffic_calming,seamark,route_master,water,airway,aerialway,building,other;true;false;false)]' . "\n"; + $overpassQuery .= 'area["ref:INSEE"="' . $insee_code . '"]->.searchArea;' . "\n"; + $overpassQuery .= 'nwr["amenity"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["shop"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["office"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["craft"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["leisure"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["healthcare"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["emergency"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["man_made"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["power"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["highway"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["railway"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["public_transport"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["landuse"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["historic"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["barrier"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["tourism"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["sport"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["place"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["waterway"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["natural"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["geological"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["route"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["military"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["traffic_sign"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["traffic_calming"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["seamark"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["route_master"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["water"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["airway"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["aerialway"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["building"]["name"](area.searchArea);' . "\n"; + $overpassQuery .= 'nwr["other"]["name"](area.searchArea);' . "\n"; + + $url = 'https://overpass-api.de/api/interpreter?data=' . urlencode($overpassQuery); + + // Rediriger vers l'API Overpass + return $this->redirect($url); + } + + #[Route('/admin/export-table-csv/{insee_code}', name: 'app_admin_export_table_csv')] + public function exportTableCsv($insee_code): Response + { + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + + if (!$stats) { + throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE'); + } + + $response = new Response(); + $response->headers->set('Content-Type', 'text/csv; charset=utf-8'); + $response->headers->set('Content-Disposition', 'attachment; filename="lieux_' . $insee_code . '_' . date('Y-m-d') . '.csv"'); + + $output = fopen('php://temp', 'r+'); + + // En-têtes CSV + fputcsv($output, [ + 'Nom', + 'Email', + 'Contenu email', + 'Completion %', + 'Type', + 'Adresse', + 'Numéro', + 'Rue', + 'Site web', + 'Accès PMR', + 'Note', + 'Texte de la note', + 'SIRET', + 'SIRET clos', + 'Dernière modif. OSM', + 'Utilisateur OSM', + 'Lien OSM' + ], ';'); + + // Données + foreach ($stats->getPlaces() as $place) { + $osmKind = $place->getOsmKind(); + $osmId = $place->getOsmId(); + $osmLink = ($osmKind && $osmId) ? 'https://www.openstreetmap.org/' . $osmKind . '/' . $osmId : ''; + + // Construire l'adresse complète + $address = ''; + if ($place->getHousenumber() && $place->getStreet()) { + $address = $place->getHousenumber() . ' ' . $place->getStreet(); + } elseif ($place->getStreet()) { + $address = $place->getStreet(); + } + + fputcsv($output, [ + $place->getName() ?: '(sans nom)', + $place->getEmail() ?: '', + $place->getEmailContent() ?: '', + $place->getCompletionPercentage(), + $place->getMainTag() ?: '', + $address, + $place->getHousenumber() ?: '', + $place->getStreet() ?: '', + $place->hasWebsite() ? 'Oui' : 'Non', + $place->hasWheelchair() ? 'Oui' : 'Non', + $place->getNote() ? 'Oui' : 'Non', + $place->getNoteContent() ?: '', + $place->getSiret() ?: '', + '', // SIRET clos - à implémenter si nécessaire + $place->getOsmDataDate() ? $place->getOsmDataDate()->format('Y-m-d H:i') : '', + $place->getOsmUser() ?: '', + $osmLink + ], ';'); + } + + rewind($output); + $csv = stream_get_contents($output); + fclose($output); + + $response->setContent($csv); + return $response; + } } diff --git a/templates/admin/import_stats.html.twig b/templates/admin/import_stats.html.twig new file mode 100644 index 0000000..24e4c78 --- /dev/null +++ b/templates/admin/import_stats.html.twig @@ -0,0 +1,115 @@ +{% extends 'base.html.twig' %} + +{% block title %}Import d'objets Stats{% endblock %} + +{% block body %} +
+
+
+
+
+

Import d'objets Stats

+
+
+
+
Instructions
+

Cette page permet d'importer des objets Stats à partir d'un fichier JSON.

+
    +
  • Seuls les objets Stats qui n'existent pas encore seront créés
  • +
  • Les objets existants (même code INSEE) seront ignorés
  • +
  • Le fichier JSON doit contenir un tableau d'objets avec les champs requis
  • +
+
+ +
+
+ + +
+ Sélectionnez un fichier JSON contenant un tableau d'objets Stats. +
+
+ +
+ + + Retour + +
+
+ +
+ +
Format attendu
+

Le fichier JSON doit contenir un tableau d'objets avec la structure suivante :

+ +
+
[
+  {
+    "zone": "75056",
+    "name": "Paris",
+    "population": 2161000,
+    "budgetAnnuel": "8500000000",
+    "siren": 200054781,
+    "codeEpci": 200054781,
+    "codesPostaux": "75001;75002;75003",
+    "decomptes": {
+      "placesCount": 1250,
+      "avecHoraires": 980,
+      "avecAdresse": 1200,
+      "avecSite": 850,
+      "avecAccessibilite": 450,
+      "avecNote": 320,
+      "completionPercent": 75
+    }
+  }
+]
+
+ +
Champs requis :
+
    +
  • zone : Code INSEE de la zone
  • +
  • name : Nom de la ville/zone
  • +
+ +
Champs optionnels :
+
    +
  • population : Population de la zone
  • +
  • budgetAnnuel : Budget annuel de la collectivité
  • +
  • siren : Code SIREN
  • +
  • codeEpci : Code EPCI
  • +
  • codesPostaux : Codes postaux (séparés par des points-virgules)
  • +
  • decomptes : Objet contenant les statistiques +
      +
    • placesCount : Nombre de lieux
    • +
    • avecHoraires : Lieux avec horaires
    • +
    • avecAdresse : Lieux avec adresse
    • +
    • avecSite : Lieux avec site web
    • +
    • avecAccessibilite : Lieux avec accessibilité
    • +
    • avecNote : Lieux avec note
    • +
    • completionPercent : Pourcentage de complétion
    • +
    +
  • +
+ +
+
Important
+
    +
  • Les objets Stats existants ne seront pas modifiés
  • +
  • Seuls les nouveaux objets seront créés
  • +
  • Assurez-vous que le fichier JSON est valide avant l'import
  • +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index 43a63f8..099b0ec 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -311,10 +311,16 @@

Tableau des {{ stats.places |length }} lieux

- - - Exporter en CSV - +
+ + + Export Overpass CSV + + + + Export Tableau CSV + +
diff --git a/templates/public/nav.html.twig b/templates/public/nav.html.twig index a39272d..f2f729b 100644 --- a/templates/public/nav.html.twig +++ b/templates/public/nav.html.twig @@ -53,6 +53,12 @@ Suivi global OSM + diff --git a/test_import_stats.json b/test_import_stats.json new file mode 100644 index 0000000..a15838d --- /dev/null +++ b/test_import_stats.json @@ -0,0 +1,56 @@ +[ + { + "zone": "75056", + "name": "Paris", + "population": 2161000, + "budgetAnnuel": "8500000000", + "siren": 200054781, + "codeEpci": 200054781, + "codesPostaux": "75001;75002;75003;75004;75005;75006;75007;75008;75009;75010;75011;75012;75013;75014;75015;75016;75017;75018;75019;75020", + "decomptes": { + "placesCount": 1250, + "avecHoraires": 980, + "avecAdresse": 1200, + "avecSite": 850, + "avecAccessibilite": 450, + "avecNote": 320, + "completionPercent": 75 + } + }, + { + "zone": "69123", + "name": "Lyon", + "population": 513275, + "budgetAnnuel": "1200000000", + "siren": 200046977, + "codeEpci": 200046977, + "codesPostaux": "69001;69002;69003;69004;69005;69006;69007;69008;69009", + "decomptes": { + "placesCount": 850, + "avecHoraires": 680, + "avecAdresse": 820, + "avecSite": 580, + "avecAccessibilite": 320, + "avecNote": 250, + "completionPercent": 72 + } + }, + { + "zone": "13055", + "name": "Marseille", + "population": 861635, + "budgetAnnuel": "1500000000", + "siren": 200054807, + "codeEpci": 200054807, + "codesPostaux": "13001;13002;13003;13004;13005;13006;13007;13008;13009;13010;13011;13012;13013;13014;13015;13016", + "decomptes": { + "placesCount": 1100, + "avecHoraires": 880, + "avecAdresse": 1050, + "avecSite": 720, + "avecAccessibilite": 380, + "avecNote": 290, + "completionPercent": 70 + } + } +] \ No newline at end of file