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 %} +
Cette page permet d'importer des objets Stats à partir d'un fichier JSON.
+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
+ }
+ }
+]
+