From f4c5e048fff5e03bf9f8354b685e804a8c898672 Mon Sep 17 00:00:00 2001 From: Tykayn Date: Wed, 16 Jul 2025 17:00:09 +0200 Subject: [PATCH] retapage accueil, gestion de Demandes --- .idea/osm-commerce-sf.iml | 2 + .idea/symfony2.xml | 6 + clean_duplicate_stats.sql | 60 ++ clean_duplicate_stats_README.md | 62 ++ migrations/Version20250716124008.php | 101 +++ migrations/Version20250716160000.php | 41 ++ public/css/main.css | 53 +- .../UpdateMostRecentCityFollowupCommand.php | 225 ++++++ .../UpdateRecentCityFollowupCommand.php | 174 +++++ src/Controller/AdminController.php | 162 ++++- src/Controller/FollowUpController.php | 63 ++ src/Controller/PublicController.php | 212 +++++- src/Entity/Demande.php | 177 +++++ src/Repository/CityFollowUpRepository.php | 38 +- src/Repository/DemandeRepository.php | 99 +++ src/Repository/StatsRepository.php | 36 + .../admin/demandes/contacted_places.html.twig | 68 ++ templates/admin/demandes/edit.html.twig | 120 ++++ templates/admin/demandes/list.html.twig | 152 +++++ templates/admin/index.html.twig | 45 +- templates/public/cities.html.twig | 138 ++++ templates/public/dashboard.html.twig | 24 +- templates/public/home.html.twig | 642 +++++++++++------- templates/public/nav.html.twig | 7 +- templates/public/rss/demandes.xml.twig | 35 + test_city_followup.php | 48 ++ 26 files changed, 2498 insertions(+), 292 deletions(-) create mode 100644 .idea/symfony2.xml create mode 100644 clean_duplicate_stats.sql create mode 100644 clean_duplicate_stats_README.md create mode 100644 migrations/Version20250716124008.php create mode 100644 migrations/Version20250716160000.php create mode 100644 src/Command/UpdateMostRecentCityFollowupCommand.php create mode 100644 src/Command/UpdateRecentCityFollowupCommand.php create mode 100644 src/Entity/Demande.php create mode 100644 src/Repository/DemandeRepository.php create mode 100644 templates/admin/demandes/contacted_places.html.twig create mode 100644 templates/admin/demandes/edit.html.twig create mode 100644 templates/admin/demandes/list.html.twig create mode 100644 templates/public/cities.html.twig create mode 100644 templates/public/rss/demandes.xml.twig create mode 100644 test_city_followup.php diff --git a/.idea/osm-commerce-sf.iml b/.idea/osm-commerce-sf.iml index ab7b1f8d..10aa9041 100644 --- a/.idea/osm-commerce-sf.iml +++ b/.idea/osm-commerce-sf.iml @@ -148,6 +148,8 @@ + + diff --git a/.idea/symfony2.xml b/.idea/symfony2.xml new file mode 100644 index 00000000..bd98e409 --- /dev/null +++ b/.idea/symfony2.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/clean_duplicate_stats.sql b/clean_duplicate_stats.sql new file mode 100644 index 00000000..f3d986a3 --- /dev/null +++ b/clean_duplicate_stats.sql @@ -0,0 +1,60 @@ +-- Script SQL pour désactiver temporairement la contrainte d'unicité sur l'INSEE de ville, +-- supprimer les doublons, et réactiver la contrainte + +-- ATTENTION: Ce script supprime des enregistrements de la table stats. +-- Les entités liées (CityFollowUp, Place) seront également supprimées si des contraintes +-- de clé étrangère avec ON DELETE CASCADE sont définies dans la base de données. +-- Assurez-vous de faire une sauvegarde de la base de données avant d'exécuter ce script. + +-- 1. Désactiver temporairement la contrainte d'unicité +ALTER TABLE stats DROP CONSTRAINT uniq_stats_zone; + +-- 2. Identifier et supprimer les doublons, en gardant l'entrée la plus ancienne +-- Créer une table temporaire pour stocker les IDs à supprimer +CREATE TEMP TABLE stats_to_delete AS +WITH duplicates AS ( + SELECT + id, + zone, + date_created, + ROW_NUMBER() OVER ( + PARTITION BY zone + ORDER BY + -- Garder l'entrée la plus ancienne si date_created existe + CASE WHEN date_created IS NOT NULL THEN 0 ELSE 1 END, + date_created, + -- Si date_created est NULL, utiliser l'ID le plus petit (probablement le plus ancien) + id + ) AS row_num + FROM stats + WHERE zone IS NOT NULL +) +SELECT id FROM duplicates WHERE row_num > 1; + +-- Afficher le nombre de doublons qui seront supprimés +SELECT COUNT(*) AS "Nombre de doublons à supprimer" FROM stats_to_delete; + +-- Afficher les détails des doublons qui seront supprimés (pour vérification) +SELECT s.id, s.zone, s.name, s.date_created +FROM stats s +JOIN stats_to_delete std ON s.id = std.id +ORDER BY s.zone, s.id; + +-- 3. Supprimer les doublons +-- Note: Nous utilisons DELETE ... USING pour éviter les problèmes de contraintes de clé étrangère +DELETE FROM stats +USING stats_to_delete +WHERE stats.id = stats_to_delete.id; + +-- 4. Nettoyer la table temporaire +DROP TABLE stats_to_delete; + +-- 5. Réactiver la contrainte d'unicité +ALTER TABLE stats ADD CONSTRAINT uniq_stats_zone UNIQUE (zone); + +-- 6. Vérifier qu'il n'y a plus de doublons +SELECT zone, COUNT(*) +FROM stats +WHERE zone IS NOT NULL +GROUP BY zone +HAVING COUNT(*) > 1; diff --git a/clean_duplicate_stats_README.md b/clean_duplicate_stats_README.md new file mode 100644 index 00000000..567bfe1f --- /dev/null +++ b/clean_duplicate_stats_README.md @@ -0,0 +1,62 @@ +# Script de nettoyage des doublons de villes + +Ce dossier contient un script SQL pour résoudre le problème de contrainte d'unicité sur le code INSEE des villes dans la table `stats`. + +## Problème + +Lorsque vous rencontrez l'erreur suivante : + +``` +SQLSTATE[23505]: Unique violation: 7 ERREUR: n'a pas pu créer l'index unique « uniq_stats_zone » +DETAIL: La clé (zone)=(91111) est dupliquée. +``` + +Cela signifie qu'il existe des doublons dans la table `stats` avec le même code INSEE (colonne `zone`), ce qui empêche la création de la contrainte d'unicité. + +## Solution + +Le script `clean_duplicate_stats.sql` permet de : + +1. Désactiver temporairement la contrainte d'unicité +2. Identifier et supprimer les doublons de villes, en gardant l'entrée la plus ancienne +3. Réactiver la contrainte d'unicité + +## Comment utiliser le script + +### Précautions importantes + +⚠️ **ATTENTION** : Ce script supprime des données de la base. Assurez-vous de faire une sauvegarde complète de votre base de données avant de l'exécuter. + +Les entités liées aux enregistrements `Stats` supprimés (comme `CityFollowUp` et `Place`) seront également supprimées si des contraintes de clé étrangère avec `ON DELETE CASCADE` sont définies dans la base de données. + +### Exécution du script + +Pour exécuter le script sur une base PostgreSQL : + +```bash +psql -U username -d database_name -f clean_duplicate_stats.sql +``` + +Remplacez `username` par votre nom d'utilisateur PostgreSQL et `database_name` par le nom de votre base de données. + +### Vérification + +Le script inclut des requêtes pour : +- Afficher le nombre de doublons qui seront supprimés +- Afficher les détails des doublons avant suppression +- Vérifier qu'il n'y a plus de doublons après l'opération + +## Fonctionnement technique + +Le script : + +1. Désactive la contrainte d'unicité `uniq_stats_zone` +2. Crée une table temporaire pour identifier les doublons +3. Utilise `ROW_NUMBER()` pour conserver l'entrée la plus ancienne de chaque groupe de doublons +4. Supprime les doublons identifiés +5. Réactive la contrainte d'unicité +6. Vérifie qu'il n'y a plus de doublons + +## Après l'exécution + +Une fois le script exécuté avec succès, vous ne devriez plus rencontrer l'erreur de violation de contrainte d'unicité lors de l'exécution des commandes Symfony. \ No newline at end of file diff --git a/migrations/Version20250716124008.php b/migrations/Version20250716124008.php new file mode 100644 index 00000000..94e96f98 --- /dev/null +++ b/migrations/Version20250716124008.php @@ -0,0 +1,101 @@ +addSql(<<<'SQL' + CREATE TABLE demande (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, query VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, insee INT DEFAULT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER email TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER note TYPE TEXT + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER name TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER note_content TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER street TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER housenumber TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER siret TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER osm_user TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER email_content TYPE TEXT + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE stats ALTER name TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX uniq_stats_zone ON stats (zone) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + DROP TABLE demande + SQL); + $this->addSql(<<<'SQL' + DROP INDEX uniq_stats_zone + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE stats ALTER name TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER email TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER note TYPE TEXT + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER name TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER note_content TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER street TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER housenumber TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER siret TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER osm_user TYPE VARCHAR(255) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE place ALTER email_content TYPE TEXT + SQL); + } +} diff --git a/migrations/Version20250716160000.php b/migrations/Version20250716160000.php new file mode 100644 index 00000000..958eee18 --- /dev/null +++ b/migrations/Version20250716160000.php @@ -0,0 +1,41 @@ +addSql(<<<'SQL' + ALTER TABLE demande ADD osm_object_type VARCHAR(10) DEFAULT NULL + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE demande ADD osm_id INT DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE demande DROP osm_object_type + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE demande DROP osm_id + SQL); + } +} \ No newline at end of file diff --git a/public/css/main.css b/public/css/main.css index fd657aca..c5f25cb2 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -60,6 +60,48 @@ padding: 0; } +/* Home page specific styles */ +.hero-image { + max-width: 100%; + height: auto; +} + +.feature-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 4rem; + height: 4rem; + margin-bottom: 1rem; + font-size: 2rem; + color: #fff; + border-radius: 0.75rem; + background-color: #0d6efd; +} + +.step-circle { + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: #0d6efd; + color: white; + font-weight: bold; + font-size: 1.25rem; + margin-right: 1rem; +} + +.card { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important; +} + /* Media queries */ @media (max-width: 768px) { .main-header h1 { @@ -69,4 +111,13 @@ .main-footer { padding: 1.5rem 0; } -} \ No newline at end of file + + .display-4 { + font-size: 2.5rem; + } + + .hero-image { + max-height: 200px; + margin-top: 2rem; + } +} diff --git a/src/Command/UpdateMostRecentCityFollowupCommand.php b/src/Command/UpdateMostRecentCityFollowupCommand.php new file mode 100644 index 00000000..f31b7f55 --- /dev/null +++ b/src/Command/UpdateMostRecentCityFollowupCommand.php @@ -0,0 +1,225 @@ +setHelp('Cette commande trouve la ville avec la mesure la plus récente, en priorisant les villes sans followup de type "rnb", et met à jour ses followups. Elle nettoie d\'abord les doublons de Stats pour éviter les violations de contrainte unique.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Mise à jour des followups pour la ville avec la mesure la plus récente'); + + // 1. Nettoyer les doublons de Stats pour éviter les violations de contrainte unique + $io->section('Nettoyage des doublons de Stats'); + + $statsByZone = []; + $statsRepo = $this->entityManager->getRepository(Stats::class); + $allStats = $statsRepo->findAll(); + + // Regrouper par code INSEE + foreach ($allStats as $stat) { + $zone = $stat->getZone(); + if (!$zone) continue; + $statsByZone[$zone][] = $stat; + } + + $toDelete = []; + $toKeep = []; + foreach ($statsByZone as $zone => $statsList) { + if (count($statsList) > 1) { + // Trier par date_created (le plus ancien d'abord), puis par id si date absente + usort($statsList, function($a, $b) { + $da = $a->getDateCreated(); + $db = $b->getDateCreated(); + if ($da && $db) { + return $da <=> $db; + } elseif ($da) { + return -1; + } elseif ($db) { + return 1; + } else { + return $a->getId() <=> $b->getId(); + } + }); + // Garder le premier, supprimer les autres + $toKeep[$zone] = $statsList[0]; + $toDelete[$zone] = array_slice($statsList, 1); + } + } + + $totalToDelete = array_sum(array_map('count', $toDelete)); + if ($totalToDelete === 0) { + $io->success('Aucun doublon trouvé.'); + } else { + $io->info("$totalToDelete doublons de Stats trouvés à supprimer."); + + foreach ($toDelete as $statsList) { + foreach ($statsList as $stat) { + $this->entityManager->remove($stat); + } + } + $this->entityManager->flush(); + $io->success("$totalToDelete doublons supprimés avec succès."); + } + + // 2. Récupérer toutes les villes (Stats) sans utiliser les colonnes problématiques + try { + $allStats = $statsRepo->findAllCitiesWithoutLabourage(); + } catch (\Exception $e) { + $io->error('Erreur lors de la récupération des villes: ' . $e->getMessage()); + return Command::FAILURE; + } + + if (empty($allStats)) { + $io->error('Aucune ville trouvée dans la base de données.'); + return Command::FAILURE; + } + + $io->info('Nombre de villes trouvées: ' . count($allStats)); + + // 3. Identifier les villes sans followup de type "rnb" + $citiesWithoutRnb = []; + $citiesWithRnb = []; + + foreach ($allStats as $stats) { + $hasRnb = false; + foreach ($stats->getCityFollowUps() as $followup) { + if ($followup->getName() === 'rnb_count' || $followup->getName() === 'rnb_completion') { + $hasRnb = true; + break; + } + } + + if (!$hasRnb) { + $citiesWithoutRnb[] = $stats; + } else { + $citiesWithRnb[] = $stats; + } + } + + $io->info('Villes sans followup "rnb": ' . count($citiesWithoutRnb)); + $io->info('Villes avec followup "rnb": ' . count($citiesWithRnb)); + + // 4. Trouver la ville avec la mesure la plus récente + $selectedCity = null; + $latestDate = null; + + // D'abord, chercher parmi les villes sans followup "rnb" + if (!empty($citiesWithoutRnb)) { + foreach ($citiesWithoutRnb as $stats) { + $latestFollowup = $this->findLatestFollowup($stats); + + if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) { + $selectedCity = $stats; + $latestDate = $latestFollowup->getDate(); + } + } + } + + // Si aucune ville sans followup "rnb" n'a été trouvée, chercher parmi toutes les villes + if ($selectedCity === null) { + foreach ($allStats as $stats) { + $latestFollowup = $this->findLatestFollowup($stats); + + if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) { + $selectedCity = $stats; + $latestDate = $latestFollowup->getDate(); + } + } + } + + if ($selectedCity === null) { + $io->error('Aucune ville avec des followups n\'a été trouvée.'); + return Command::FAILURE; + } + + $io->section('Ville sélectionnée: ' . $selectedCity->getName() . ' (' . $selectedCity->getZone() . ')'); + if ($latestDate) { + $io->info('Date de la dernière mesure: ' . $latestDate->format('Y-m-d H:i:s')); + } + + // 5. Mettre à jour les followups pour la ville sélectionnée + $io->section('Mise à jour des followups'); + + try { + $this->followUpService->generateCityFollowUps( + $selectedCity, + $this->motocultrice, + $this->entityManager + ); + + $io->success('Followups mis à jour avec succès pour ' . $selectedCity->getName()); + + // Afficher les résultats + $newFollowups = $selectedCity->getCityFollowUps(); + $io->section('Résultats'); + + $table = []; + foreach ($newFollowups as $followup) { + if ($followup->getDate() > (new \DateTime())->modify('-1 hour')) { + $table[] = [ + $followup->getName(), + $followup->getMeasure(), + $followup->getDate()->format('Y-m-d H:i:s') + ]; + } + } + + if (!empty($table)) { + $io->table(['Nom', 'Valeur', 'Date'], $table); + } else { + $io->info('Aucun nouveau followup généré dans la dernière heure.'); + } + + } catch (\Exception $e) { + $io->error('Erreur lors de la mise à jour des followups: ' . $e->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + /** + * Trouve le followup le plus récent pour une ville donnée + */ + private function findLatestFollowup(Stats $stats): ?CityFollowUp + { + $latestFollowup = null; + + foreach ($stats->getCityFollowUps() as $followup) { + if ($latestFollowup === null || $followup->getDate() > $latestFollowup->getDate()) { + $latestFollowup = $followup; + } + } + + return $latestFollowup; + } +} \ No newline at end of file diff --git a/src/Command/UpdateRecentCityFollowupCommand.php b/src/Command/UpdateRecentCityFollowupCommand.php new file mode 100644 index 00000000..08452930 --- /dev/null +++ b/src/Command/UpdateRecentCityFollowupCommand.php @@ -0,0 +1,174 @@ +setHelp('Cette commande trouve la ville avec la mesure la plus récente, en priorisant les villes sans followup de type "rnb", et met à jour ses followups.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Mise à jour des followups pour la ville avec la mesure la plus récente'); + + // 1. Récupérer toutes les villes (Stats) sans utiliser les colonnes problématiques + $statsRepo = $this->entityManager->getRepository(Stats::class); + try { + $allStats = $statsRepo->findAllCitiesWithoutLabourage(); + } catch (\Exception $e) { + $io->error('Erreur lors de la récupération des villes: ' . $e->getMessage()); + return Command::FAILURE; + } + + if (empty($allStats)) { + $io->error('Aucune ville trouvée dans la base de données.'); + return Command::FAILURE; + } + + $io->info('Nombre de villes trouvées: ' . count($allStats)); + + // 2. Identifier les villes sans followup de type "rnb" + $citiesWithoutRnb = []; + $citiesWithRnb = []; + + foreach ($allStats as $stats) { + $hasRnb = false; + foreach ($stats->getCityFollowUps() as $followup) { + if ($followup->getName() === 'rnb_count' || $followup->getName() === 'rnb_completion') { + $hasRnb = true; + break; + } + } + + if (!$hasRnb) { + $citiesWithoutRnb[] = $stats; + } else { + $citiesWithRnb[] = $stats; + } + } + + $io->info('Villes sans followup "rnb": ' . count($citiesWithoutRnb)); + $io->info('Villes avec followup "rnb": ' . count($citiesWithRnb)); + + // 3. Trouver la ville avec la mesure la plus récente + $selectedCity = null; + $latestDate = null; + + // D'abord, chercher parmi les villes sans followup "rnb" + if (!empty($citiesWithoutRnb)) { + foreach ($citiesWithoutRnb as $stats) { + $latestFollowup = $this->findLatestFollowup($stats); + + if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) { + $selectedCity = $stats; + $latestDate = $latestFollowup->getDate(); + } + } + } + + // Si aucune ville sans followup "rnb" n'a été trouvée, chercher parmi toutes les villes + if ($selectedCity === null) { + foreach ($allStats as $stats) { + $latestFollowup = $this->findLatestFollowup($stats); + + if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) { + $selectedCity = $stats; + $latestDate = $latestFollowup->getDate(); + } + } + } + + if ($selectedCity === null) { + $io->error('Aucune ville avec des followups n\'a été trouvée.'); + return Command::FAILURE; + } + + $io->section('Ville sélectionnée: ' . $selectedCity->getName() . ' (' . $selectedCity->getZone() . ')'); + if ($latestDate) { + $io->info('Date de la dernière mesure: ' . $latestDate->format('Y-m-d H:i:s')); + } + + // 4. Mettre à jour les followups pour la ville sélectionnée + $io->section('Mise à jour des followups'); + + try { + $this->followUpService->generateCityFollowUps( + $selectedCity, + $this->motocultrice, + $this->entityManager + ); + + $io->success('Followups mis à jour avec succès pour ' . $selectedCity->getName()); + + // Afficher les résultats + $newFollowups = $selectedCity->getCityFollowUps(); + $io->section('Résultats'); + + $table = []; + foreach ($newFollowups as $followup) { + if ($followup->getDate() > (new \DateTime())->modify('-1 hour')) { + $table[] = [ + $followup->getName(), + $followup->getMeasure(), + $followup->getDate()->format('Y-m-d H:i:s') + ]; + } + } + + if (!empty($table)) { + $io->table(['Nom', 'Valeur', 'Date'], $table); + } else { + $io->info('Aucun nouveau followup généré dans la dernière heure.'); + } + + } catch (\Exception $e) { + $io->error('Erreur lors de la mise à jour des followups: ' . $e->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + /** + * Trouve le followup le plus récent pour une ville donnée + */ + private function findLatestFollowup(Stats $stats): ?CityFollowUp + { + $latestFollowup = null; + + foreach ($stats->getCityFollowUps() as $followup) { + if ($latestFollowup === null || $followup->getDate() > $latestFollowup->getDate()) { + $latestFollowup = $followup; + } + } + + return $latestFollowup; + } +} diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 36acab70..ea7df395 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -489,7 +489,7 @@ final class AdminController extends AbstractController $progression7Days[$type] = \App\Service\FollowUpService::calculate7DayProgression($stats, $type); } $progression7Days['places'] = \App\Service\FollowUpService::calculate7DayProgression($stats, 'places'); - + // --- Ajout : mesures CTC CityFollowUp pour le graphique d'évolution --- $ctc_completion_series = []; foreach ($stats->getCityFollowUps() as $fu) { @@ -1908,4 +1908,164 @@ final class AdminController extends AbstractController 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, ]); } + + #[Route('/admin/demandes', name: 'app_admin_demandes')] + public function listDemandes(Request $request): Response + { + $status = $request->query->get('status'); + + $repository = $this->entityManager->getRepository(\App\Entity\Demande::class); + + if ($status) { + $demandes = $repository->findByStatus($status); + } else { + $demandes = $repository->findAllOrderedByCreatedAt(); + } + + // Get all possible statuses for the filter + $allStatuses = ['new', 'email_provided', 'ready', 'email_sent', 'email_failed', 'email_opened', 'edit_form_opened', 'place_modified', 'linked_to_place']; + + // Count demandes for each status + $statusCounts = []; + foreach ($allStatuses as $statusValue) { + $statusCounts[$statusValue] = $repository->findByStatus($statusValue); + } + + // Get total count + $totalCount = $repository->findAllOrderedByCreatedAt(); + + return $this->render('admin/demandes/list.html.twig', [ + 'demandes' => $demandes, + 'current_status' => $status, + 'all_statuses' => $allStatuses, + 'status_counts' => $statusCounts, + 'total_count' => count($totalCount) + ]); + } + + #[Route('/admin/demandes/{id}/edit', name: 'app_admin_demande_edit')] + public function editDemande(int $id, Request $request): Response + { + $demande = $this->entityManager->getRepository(\App\Entity\Demande::class)->find($id); + + if (!$demande) { + $this->addFlash('error', 'Demande non trouvée'); + return $this->redirectToRoute('app_admin_demandes'); + } + + if ($request->isMethod('POST')) { + $placeUuid = $request->request->get('placeUuid'); + + if ($placeUuid) { + // Check if the Place exists + $place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $placeUuid]); + + if ($place) { + $demande->setPlaceUuid($placeUuid); + $demande->setPlace($place); + $demande->setStatus('linked_to_place'); + + // Set OSM object type and OSM ID from the Place + $demande->setOsmObjectType($place->getOsmKind()); + $demande->setOsmId((int)$place->getOsmId()); + + $this->entityManager->persist($demande); + $this->entityManager->flush(); + + $this->addFlash('success', 'Demande mise à jour avec succès'); + } else { + $this->addFlash('error', 'Place non trouvée avec cet UUID'); + } + } + } + + return $this->render('admin/demandes/edit.html.twig', [ + 'demande' => $demande + ]); + } + + #[Route('/admin/contacted-places', name: 'app_admin_contacted_places')] + public function listContactedPlaces(): Response + { + $demandes = $this->entityManager->getRepository(\App\Entity\Demande::class)->findPlacesWithContactAttempt(); + + return $this->render('admin/demandes/contacted_places.html.twig', [ + 'demandes' => $demandes + ]); + } + + #[Route('/admin/demandes/{id}/send-email', name: 'app_admin_demande_send_email')] + public function sendEmailToDemande(int $id, \Symfony\Component\Mailer\MailerInterface $mailer): Response + { + $demande = $this->entityManager->getRepository(\App\Entity\Demande::class)->find($id); + + if (!$demande) { + $this->addFlash('error', 'Demande non trouvée'); + return $this->redirectToRoute('app_admin_demandes'); + } + + $place = $demande->getPlace(); + + if (!$place) { + $this->addFlash('error', 'Aucune place associée à cette demande'); + return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]); + } + + // Check if the place has an email + if (!$place->getEmail() && !$demande->getEmail()) { + $this->addFlash('error', 'Aucun email associé à cette place ou à cette demande'); + return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]); + } + + // Use the email from the place if available, otherwise use the email from the demande + $email = $place->getEmail() ?: $demande->getEmail(); + + // Generate the email content + $emailContent = $this->renderView('admin/email_content.html.twig', [ + 'place' => $place + ]); + + // Only send the email in production environment + if ($this->getParameter('kernel.environment') === 'prod') { + $message = (new \Symfony\Component\Mime\Email()) + ->from('contact@osm-commerce.fr') + ->to($email) + ->subject('Votre lien de modification OpenStreetMap') + ->html($emailContent); + + try { + $mailer->send($message); + } catch (\Throwable $e) { + $this->actionLogger->log('ERROR_envoi_email', [ + 'demande_id' => $demande->getId(), + 'place_id' => $place->getId(), + 'message' => $e->getMessage(), + ]); + $this->addFlash('error', 'Erreur lors de l\'envoi de l\'email : ' . $e->getMessage()); + return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]); + } + } else { + // In non-production environments, just log the attempt + $this->actionLogger->log('email_would_be_sent', [ + 'demande_id' => $demande->getId(), + 'place_id' => $place->getId(), + 'email' => $email, + 'content' => $emailContent + ]); + $this->addFlash('info', 'En environnement de production, un email serait envoyé à ' . $email); + } + + // Update the last contact attempt date and set status to email_sent + $now = new \DateTime(); + $demande->setLastContactAttempt($now); + $demande->setStatus('email_sent'); + $place->setLastContactAttemptDate($now); + + $this->entityManager->persist($demande); + $this->entityManager->persist($place); + $this->entityManager->flush(); + + $this->addFlash('success', 'Email envoyé avec succès'); + return $this->redirectToRoute('app_admin_contacted_places'); + } } diff --git a/src/Controller/FollowUpController.php b/src/Controller/FollowUpController.php index 1b1687c5..f2da51a2 100644 --- a/src/Controller/FollowUpController.php +++ b/src/Controller/FollowUpController.php @@ -21,6 +21,69 @@ class FollowUpController extends AbstractController $this->followUpService = $followUpService; } + #[Route('/api/city-followup', name: 'api_city_followup', methods: ['POST'])] + public function recordCityFollowUp( + EntityManagerInterface $em, + \Symfony\Component\HttpFoundation\Request $request + ): Response { + $insee_code = $request->request->get('insee_code'); + $measure_label = $request->request->get('measure_label'); + $measure_value = $request->request->getFloat('measure_value'); + + if (!$insee_code || !$measure_label || $measure_value === null) { + return $this->json([ + 'success' => false, + 'message' => 'Missing required parameters: insee_code, measure_label, measure_value' + ], Response::HTTP_BAD_REQUEST); + } + + $stats = $em->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + if (!$stats) { + return $this->json([ + 'success' => false, + 'message' => 'No stats found for this INSEE code' + ], Response::HTTP_NOT_FOUND); + } + + // Check if the same measure was recorded less than an hour ago + $oneHourAgo = new \DateTime('-1 hour'); + $recentFollowUp = $em->getRepository(CityFollowUp::class) + ->findRecentByStatsAndName($stats, $measure_label, $oneHourAgo); + + if ($recentFollowUp) { + return $this->json([ + 'success' => false, + 'message' => 'A measure with the same label was recorded less than an hour ago', + 'existing_measure' => [ + 'id' => $recentFollowUp->getId(), + 'date' => $recentFollowUp->getDate()->format('Y-m-d H:i:s'), + 'value' => $recentFollowUp->getMeasure() + ] + ], Response::HTTP_TOO_MANY_REQUESTS); + } + + // Create and save the new follow-up + $followUp = new CityFollowUp(); + $followUp->setName($measure_label); + $followUp->setMeasure($measure_value); + $followUp->setDate(new \DateTime()); + $followUp->setStats($stats); + + $em->persist($followUp); + $em->flush(); + + return $this->json([ + 'success' => true, + 'message' => 'City follow-up recorded successfully', + 'follow_up' => [ + 'id' => $followUp->getId(), + 'name' => $followUp->getName(), + 'measure' => $followUp->getMeasure(), + 'date' => $followUp->getDate()->format('Y-m-d H:i:s') + ] + ]); + } + #[Route('/admin/followup/{insee_code}/delete', name: 'admin_followup_delete', requirements: ['insee_code' => '\\d+'])] public function deleteFollowups(string $insee_code, EntityManagerInterface $em): Response { diff --git a/src/Controller/PublicController.php b/src/Controller/PublicController.php index 8b6411f3..6f0bf384 100644 --- a/src/Controller/PublicController.php +++ b/src/Controller/PublicController.php @@ -4,16 +4,21 @@ namespace App\Controller; use App\Entity\Stats; use App\Entity\Place; +use App\Entity\CityFollowUp; +use App\Entity\Demande; use App\Service\Motocultrice; +use App\Service\FollowUpService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Annotation\Route; use GuzzleHttp\Client; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Mime\Email; use Symfony\Component\Mailer\MailerInterface; use App\Service\ActionLogger; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; class PublicController extends AbstractController { @@ -24,7 +29,8 @@ class PublicController extends AbstractController private EntityManagerInterface $entityManager, private Motocultrice $motocultrice, private MailerInterface $mailer, - private ActionLogger $actionLogger + private ActionLogger $actionLogger, + private FollowUpService $followUpService ) {} #[Route('/propose-email/{email}/{type}/{id}', name: 'app_public_propose_email')] @@ -115,6 +121,73 @@ class PublicController extends AbstractController return $this->redirectToRoute('app_public_index'); } + #[Route('/api/demande/create', name: 'app_public_create_demande', methods: ['POST'])] + public function createDemande(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!isset($data['businessName']) || empty($data['businessName'])) { + return new JsonResponse(['success' => false, 'message' => 'Le nom du commerce est requis'], 400); + } + + // Create a new Demande + $demande = new Demande(); + $demande->setQuery($data['businessName']); + $demande->setStatus('new'); + $demande->setCreatedAt(new \DateTime()); + + // Save the INSEE code if provided + if (isset($data['insee']) && !empty($data['insee'])) { + $demande->setInsee((int)$data['insee']); + } + + // Save the OSM object type if provided + if (isset($data['osmObjectType']) && !empty($data['osmObjectType'])) { + $demande->setOsmObjectType($data['osmObjectType']); + } + + // Save the OSM ID if provided + if (isset($data['osmId']) && !empty($data['osmId'])) { + $demande->setOsmId((int)$data['osmId']); + } + + $this->entityManager->persist($demande); + $this->entityManager->flush(); + + return new JsonResponse([ + 'success' => true, + 'message' => 'Demande créée avec succès', + 'id' => $demande->getId() + ]); + } + + #[Route('/api/demande/{id}/email', name: 'app_public_update_demande_email', methods: ['POST'])] + public function updateDemandeEmail(int $id, Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true); + + if (!isset($data['email']) || empty($data['email'])) { + return new JsonResponse(['success' => false, 'message' => 'L\'email est requis'], 400); + } + + $demande = $this->entityManager->getRepository(Demande::class)->find($id); + + if (!$demande) { + return new JsonResponse(['success' => false, 'message' => 'Demande non trouvée'], 404); + } + + $demande->setEmail($data['email']); + $demande->setStatus('email_provided'); + + $this->entityManager->persist($demande); + $this->entityManager->flush(); + + return new JsonResponse([ + 'success' => true, + 'message' => 'Email ajouté avec succès' + ]); + } + #[Route('/', name: 'app_public_index')] public function index(): Response { @@ -122,11 +195,11 @@ class PublicController extends AbstractController // Préparer les données pour la carte $citiesForMap = []; - + foreach ($stats as $stat) { if ($stat->getZone() && $stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone()) && $stat->getZone() !== '00000') { $cityName = $stat->getName() ?: $stat->getZone(); - + // Utiliser les coordonnées stockées si disponibles if ($stat->getLat() && $stat->getLon()) { $citiesForMap[] = [ @@ -160,33 +233,33 @@ class PublicController extends AbstractController { // Cache simple pour éviter trop d'appels API $cacheKey = 'city_coords_' . $inseeCode; - + // Vérifier le cache (ici on utilise une approche simple) // En production, vous pourriez utiliser le cache Symfony - + $query = urlencode($cityName . ', France'); $url = "https://nominatim.openstreetmap.org/search?q={$query}&format=json&limit=1&countrycodes=fr"; - + try { // Ajouter un délai pour respecter les limites de l'API Nominatim usleep(100000); // 0.1 seconde entre les appels - + $context = stream_context_create([ 'http' => [ 'timeout' => 5, // Timeout de 5 secondes 'user_agent' => 'OSM-Commerces/1.0' ] ]); - + $response = file_get_contents($url, false, $context); - + if ($response === false) { error_log("DEBUG: Échec de récupération des coordonnées pour $cityName ($inseeCode)"); return null; } - + $data = json_decode($response, true); - + if (!empty($data) && isset($data[0]['lat']) && isset($data[0]['lon'])) { error_log("DEBUG: Coordonnées trouvées pour $cityName ($inseeCode): " . $data[0]['lat'] . ", " . $data[0]['lon']); return [ @@ -199,7 +272,7 @@ class PublicController extends AbstractController } catch (\Exception $e) { error_log("DEBUG: Exception lors de la récupération des coordonnées pour $cityName ($inseeCode): " . $e->getMessage()); } - + return null; } @@ -262,7 +335,7 @@ class PublicController extends AbstractController { $this->actionLogger->log('dashboard', []); - + $stats_repo = $this->entityManager->getRepository(Stats::class)->findAll(); $stats_for_chart = []; @@ -291,6 +364,74 @@ class PublicController extends AbstractController ]); } + #[Route('/api/dashboard/regression', name: 'api_dashboard_regression', methods: ['POST'])] + public function saveRegressionData(Request $request): JsonResponse + { + $this->actionLogger->log('save_regression_data', []); + + // Récupérer les données de la requête + $data = json_decode($request->getContent(), true); + + if (!isset($data['angle']) || !isset($data['slope']) || !isset($data['intercept'])) { + return new JsonResponse([ + 'success' => false, + 'message' => 'Données de régression incomplètes' + ], Response::HTTP_BAD_REQUEST); + } + + // Récupérer les stats globales (zone 00000) + $statsGlobal = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => '00000']); + + if (!$statsGlobal) { + // Créer les stats globales si elles n'existent pas + $statsGlobal = new Stats(); + $statsGlobal->setZone('00000'); + $statsGlobal->setName('toutes les villes'); + $this->entityManager->persist($statsGlobal); + $this->entityManager->flush(); + } + + // Créer un nouveau followup pour la régression linéaire + $followup = new CityFollowUp(); + $followup->setName('regression_angle'); + $followup->setMeasure($data['angle']); + $followup->setDate(new \DateTime()); + $followup->setStats($statsGlobal); + + $this->entityManager->persist($followup); + + // Créer un followup pour la pente + $followupSlope = new CityFollowUp(); + $followupSlope->setName('regression_slope'); + $followupSlope->setMeasure($data['slope']); + $followupSlope->setDate(new \DateTime()); + $followupSlope->setStats($statsGlobal); + + $this->entityManager->persist($followupSlope); + + // Créer un followup pour l'ordonnée à l'origine + $followupIntercept = new CityFollowUp(); + $followupIntercept->setName('regression_intercept'); + $followupIntercept->setMeasure($data['intercept']); + $followupIntercept->setDate(new \DateTime()); + $followupIntercept->setStats($statsGlobal); + + $this->entityManager->persist($followupIntercept); + + $this->entityManager->flush(); + + return new JsonResponse([ + 'success' => true, + 'message' => 'Données de régression enregistrées avec succès', + 'followup' => [ + 'id' => $followup->getId(), + 'name' => $followup->getName(), + 'measure' => $followup->getMeasure(), + 'date' => $followup->getDate()->format('Y-m-d H:i:s') + ] + ]); + } + #[Route('/modify/{osm_object_id}/{version}/{changesetID}', name: 'app_public_submit')] public function submit($osm_object_id, $version, $changesetID): Response { @@ -666,7 +807,7 @@ class PublicController extends AbstractController $followups = $stats->getCityFollowUps(); $countData = []; $completionData = []; - + foreach ($followups as $fu) { if ($fu->getName() === $theme . '_count') { $countData[] = [ @@ -849,4 +990,47 @@ class PublicController extends AbstractController 'places_6mois' => $places_6mois, ]); } + + #[Route('/rss/demandes', name: 'app_public_rss_demandes')] + public function rssDemandes(): Response + { + $demandes = $this->entityManager->getRepository(Demande::class)->findAllOrderedByCreatedAt(); + + $content = $this->renderView('public/rss/demandes.xml.twig', [ + 'demandes' => $demandes, + 'base_url' => $this->getParameter('router.request_context.host'), + ]); + + $response = new Response($content); + $response->headers->set('Content-Type', 'application/rss+xml'); + + return $response; + } + + #[Route('/cities', name: 'app_public_cities')] + public function cities(): Response + { + $stats = $this->entityManager->getRepository(Stats::class)->findAll(); + + // Prepare data for the map + $citiesForMap = []; + foreach ($stats as $stat) { + if ($stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone())) { + $citiesForMap[] = [ + 'name' => $stat->getName(), + 'zone' => $stat->getZone(), + 'lat' => $stat->getLat(), + 'lon' => $stat->getLon(), + 'placesCount' => $stat->getPlacesCount(), + 'completionPercent' => $stat->getCompletionPercent(), + ]; + } + } + + return $this->render('public/cities.html.twig', [ + 'stats' => $stats, + 'citiesForMap' => $citiesForMap, + 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, + ]); + } } diff --git a/src/Entity/Demande.php b/src/Entity/Demande.php new file mode 100644 index 00000000..0e96f056 --- /dev/null +++ b/src/Entity/Demande.php @@ -0,0 +1,177 @@ +createdAt = new \DateTime(); + $this->status = 'new'; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuery(): ?string + { + return $this->query; + } + + public function setQuery(string $query): static + { + $this->query = $query; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getInsee(): ?int + { + return $this->insee; + } + + public function setInsee(?int $insee): static + { + $this->insee = $insee; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getPlaceUuid(): ?string + { + return $this->placeUuid; + } + + public function setPlaceUuid(?string $placeUuid): static + { + $this->placeUuid = $placeUuid; + + return $this; + } + + public function getLastContactAttempt(): ?\DateTimeInterface + { + return $this->lastContactAttempt; + } + + public function setLastContactAttempt(?\DateTimeInterface $lastContactAttempt): static + { + $this->lastContactAttempt = $lastContactAttempt; + + return $this; + } + + public function getPlace(): ?Place + { + return $this->place; + } + + public function setPlace(?Place $place): static + { + $this->place = $place; + + return $this; + } + + public function getOsmObjectType(): ?string + { + return $this->osmObjectType; + } + + public function setOsmObjectType(?string $osmObjectType): static + { + $this->osmObjectType = $osmObjectType; + + return $this; + } + + public function getOsmId(): ?int + { + return $this->osmId; + } + + public function setOsmId(?int $osmId): static + { + $this->osmId = $osmId; + + return $this; + } +} diff --git a/src/Repository/CityFollowUpRepository.php b/src/Repository/CityFollowUpRepository.php index df0a5229..e2e1b71a 100644 --- a/src/Repository/CityFollowUpRepository.php +++ b/src/Repository/CityFollowUpRepository.php @@ -16,28 +16,20 @@ class CityFollowUpRepository extends ServiceEntityRepository parent::__construct($registry, CityFollowUp::class); } - // /** - // * @return CityFollowUp[] Returns an array of CityFollowUp objects - // */ - // public function findByExampleField($value): array - // { - // return $this->createQueryBuilder('c') - // ->andWhere('c.exampleField = :val') - // ->setParameter('val', $value) - // ->orderBy('c.id', 'ASC') - // ->setMaxResults(10) - // ->getQuery() - // ->getResult() - // ; - // } - // public function findOneBySomeField($value): ?CityFollowUp - // { - // return $this->createQueryBuilder('c') - // ->andWhere('c.exampleField = :val') - // ->setParameter('val', $value) - // ->getQuery() - // ->getOneOrNullResult() - // ; - // } + public function findRecentByStatsAndName(Stats $stats, string $name, \DateTime $since): ?CityFollowUp + { + return $this->createQueryBuilder('c') + ->andWhere('c.stats = :stats') + ->andWhere('c.name = :name') + ->andWhere('c.date >= :since') + ->setParameter('stats', $stats) + ->setParameter('name', $name) + ->setParameter('since', $since) + ->orderBy('c.date', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } } diff --git a/src/Repository/DemandeRepository.php b/src/Repository/DemandeRepository.php new file mode 100644 index 00000000..4b3809dc --- /dev/null +++ b/src/Repository/DemandeRepository.php @@ -0,0 +1,99 @@ + + */ +class DemandeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Demande::class); + } + + /** + * Find the most recent Demande by query (business name) + */ + public function findMostRecentByQuery(string $query): ?Demande + { + return $this->createQueryBuilder('d') + ->andWhere('d.query = :query') + ->setParameter('query', $query) + ->orderBy('d.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult() + ; + } + + /** + * Find Demandes without an email + */ + public function findWithoutEmail(): array + { + return $this->createQueryBuilder('d') + ->andWhere('d.email IS NULL') + ->orderBy('d.createdAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + /** + * Find Demandes by status + */ + public function findByStatus(string $status): array + { + return $this->createQueryBuilder('d') + ->andWhere('d.status = :status') + ->setParameter('status', $status) + ->orderBy('d.createdAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + /** + * Find Demandes with a Place UUID + */ + public function findWithPlaceUuid(): array + { + return $this->createQueryBuilder('d') + ->andWhere('d.placeUuid IS NOT NULL') + ->orderBy('d.createdAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + /** + * Find all Demandes in reverse chronological order + */ + public function findAllOrderedByCreatedAt(): array + { + return $this->createQueryBuilder('d') + ->orderBy('d.createdAt', 'DESC') + ->getQuery() + ->getResult() + ; + } + + /** + * Find Places that have been contacted, ordered by last contact attempt + */ + public function findPlacesWithContactAttempt(): array + { + return $this->createQueryBuilder('d') + ->andWhere('d.lastContactAttempt IS NOT NULL') + ->andWhere('d.place IS NOT NULL') + ->orderBy('d.lastContactAttempt', 'DESC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Repository/StatsRepository.php b/src/Repository/StatsRepository.php index a5bd992b..8dfad185 100644 --- a/src/Repository/StatsRepository.php +++ b/src/Repository/StatsRepository.php @@ -16,6 +16,42 @@ class StatsRepository extends ServiceEntityRepository parent::__construct($registry, Stats::class); } + /** + * Find all cities without using problematic columns + * + * @return Stats[] Returns an array of Stats objects + */ + public function findAllCitiesWithoutLabourage(): array + { + // Use native SQL to avoid ORM mapping issues with missing columns + $conn = $this->getEntityManager()->getConnection(); + $sql = ' + SELECT id, zone, completion_percent, places_count, avec_horaires, + avec_adresse, avec_site, avec_accessibilite, avec_note, + name, population, siren, code_epci, codes_postaux, + date_created, date_modified, avec_siret, avec_name, + osm_data_date_min, osm_data_date_avg, osm_data_date_max, + budget_annuel, lat, lon + FROM stats + WHERE zone != :global_zone + '; + $stmt = $conn->prepare($sql); + $resultSet = $stmt->executeQuery(['global_zone' => '00000']); + + $results = $resultSet->fetchAllAssociative(); + + // Get existing Stats entities by ID + $statsEntities = []; + foreach ($results as $row) { + $stats = $this->find($row['id']); + if ($stats) { + $statsEntities[] = $stats; + } + } + + return $statsEntities; + } + // /** // * @return Stats[] Returns an array of Stats objects // */ diff --git a/templates/admin/demandes/contacted_places.html.twig b/templates/admin/demandes/contacted_places.html.twig new file mode 100644 index 00000000..11732fbb --- /dev/null +++ b/templates/admin/demandes/contacted_places.html.twig @@ -0,0 +1,68 @@ +{% extends 'base.html.twig' %} + +{% block title %}Places contactées{% endblock %} + +{% block body %} +
+

Places contactées

+ + + +
+
+

Places contactées ({{ demandes|length }})

+
+
+
+ + + + + + + + + + + + + {% for demande in demandes %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDNom du commerceEmailPlaceDernière tentative de contactActions
{{ demande.id }}{{ demande.query }}{{ demande.email }} + {% if demande.place %} + {{ demande.place.name }} + ({{ demande.place.osmKind }}/{{ demande.place.osmId }}) + {% else %} + Non liée + {% endif %} + {{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }} + +
Aucune place contactée trouvée
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/demandes/edit.html.twig b/templates/admin/demandes/edit.html.twig new file mode 100644 index 00000000..bead3af2 --- /dev/null +++ b/templates/admin/demandes/edit.html.twig @@ -0,0 +1,120 @@ +{% extends 'base.html.twig' %} + +{% block title %}Éditer une demande{% endblock %} + +{% block body %} +
+

Éditer une demande

+ + + +
+
+
+
+

Informations de la demande

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ demande.id }}
Nom du commerce{{ demande.query }}
Email{{ demande.email }}
Date de création{{ demande.createdAt ? demande.createdAt|date('Y-m-d H:i:s') : '' }}
Statut + + {{ demande.status }} + +
Place UUID{{ demande.placeUuid }}
Dernière tentative de contact{{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}
+
+
+ + {% if demande.place %} +
+
+

Place associée

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
ID{{ demande.place.id }}
Nom{{ demande.place.name }}
Email{{ demande.place.email }}
UUID{{ demande.place.uuidForUrl }}
OSM ID{{ demande.place.osmKind }}/{{ demande.place.osmId }}
+ + +
+
+ {% endif %} +
+ +
+
+
+

Lier à une Place

+
+
+
+
+ + +
Entrez l'UUID d'une Place existante pour la lier à cette demande.
+
+ + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/demandes/list.html.twig b/templates/admin/demandes/list.html.twig new file mode 100644 index 00000000..96925ba0 --- /dev/null +++ b/templates/admin/demandes/list.html.twig @@ -0,0 +1,152 @@ +{% extends 'base.html.twig' %} + +{% block title %}Liste des demandes{% endblock %} + +{% block body %} +
+

Liste des demandes

+ + + + + +
+
+

+ Demandes ({{ demandes|length }}) + {% if current_status %} + + - Filtre actif : + {% if current_status == 'new' %} + Nouveau + {% elseif current_status == 'email_provided' %} + Email fourni + {% elseif current_status == 'ready' %} + Prêt + {% elseif current_status == 'email_sent' %} + Email envoyé + {% elseif current_status == 'email_failed' %} + Échec d'envoi + {% elseif current_status == 'email_opened' %} + Email ouvert + {% elseif current_status == 'edit_form_opened' %} + Formulaire ouvert + {% elseif current_status == 'place_modified' %} + Place modifiée + {% elseif current_status == 'linked_to_place' %} + Lié à une place + {% else %} + {{ current_status }} + {% endif %} + + {% endif %} +

+
+
+
+ + + + + + + + + + + + + + + {% for demande in demandes %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDNom du commerceEmailDate de créationStatutPlace UUIDDernière tentative de contactActions
{{ demande.id }}{{ demande.query }}{{ demande.email }}{{ demande.createdAt ? demande.createdAt|date('Y-m-d H:i:s') : '' }} + + {{ demande.status }} + + {{ demande.placeUuid }}{{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }} +
+ + Éditer + + {% if demande.place %} + + Envoyer un email + + {% endif %} +
+
Aucune demande trouvée
+
+
+
+
+{% endblock %} diff --git a/templates/admin/index.html.twig b/templates/admin/index.html.twig index 0001148a..ed85928b 100644 --- a/templates/admin/index.html.twig +++ b/templates/admin/index.html.twig @@ -1,15 +1,44 @@ {% extends 'base.html.twig' %} -{% block title %}Hello AdminController!{% endblock %} +{% block title %}Administration{% endblock %} {% block body %} - +
+

Administration

-
-

Hello {{ controller_name }}! ✅

- +
+
+
+
+

Gestion des demandes

+
+ +
+
+ +
+
+
+

Autres actions

+
+ +
+
+
{% endblock %} diff --git a/templates/public/cities.html.twig b/templates/public/cities.html.twig new file mode 100644 index 00000000..2abe76ca --- /dev/null +++ b/templates/public/cities.html.twig @@ -0,0 +1,138 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'display.title'|trans }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + + +{% endblock %} + +{% block body %} +{% if citiesForMap is not empty %} +
+ +
+ Les contributrices et contributeurs aguerris d'OSM peuvent ajouter ici de quoi suivre les évolutions thématiques de complétion dans une ville donnée. +
+
+
+
+
+
+

Carte des villes disponibles

+

Cliquez sur un marqueur pour voir les statistiques de la ville

+
+ + Ajouter ma ville + +
+
+
+
+
+
+
+ Complétion > 80% +
+
+
+ Complétion 50-80% +
+
+
+ Complétion < 50% +
+
+
+
+
+
+
+
+
+

Villes disponibles

+

Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre + ville grâce à OpenStreetMap

+ +
+ {% set sorted_stats = stats|sort((a, b) => a.zone <=> b.zone) %} + {% for stat in sorted_stats %} + + + {% if stat.zone != 'undefined' and stat.zone matches '/^\\d+$/' %} + +
+ {{ stat.zone }} + {{ stat.name }} +
+
+ {{ stat.placesCount }} lieux + {{ stat.completionPercent }}% +
+
+ {% endif %} + {% endfor %} + {% include 'public/labourage-form.html.twig' %} +
+
+
+{% endif %} + + + +{% endblock %} +{% block javascripts %} + {{ parent() }} + + +{% endblock %} diff --git a/templates/public/dashboard.html.twig b/templates/public/dashboard.html.twig index 034ce2bc..d9e2606e 100644 --- a/templates/public/dashboard.html.twig +++ b/templates/public/dashboard.html.twig @@ -88,7 +88,7 @@
- + @@ -314,6 +314,26 @@ const angleElem = document.getElementById('dashboard-regression-angle'); if (angleElem) angleElem.innerText = angleStr; console.log('Angle de la régression linéaire :', angleStr); + + // Envoyer les données de régression au serveur pour créer un followup + fetch('/api/dashboard/regression', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + angle: angle, + slope: slope, + intercept: intercept + }), + }) + .then(response => response.json()) + .then(data => { + console.log('Données de régression enregistrées:', data); + }) + .catch((error) => { + console.error('Erreur lors de l\'enregistrement des données de régression:', error); + }); } Chart.register(window.ChartDataLabels); const bubbleChart = new Chart(chartCanvas.getContext('2d'), { @@ -482,4 +502,4 @@ } }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/public/home.html.twig b/templates/public/home.html.twig index 6ec37095..256ab71d 100644 --- a/templates/public/home.html.twig +++ b/templates/public/home.html.twig @@ -26,7 +26,7 @@ background: white; padding: 10px; border-radius: 4px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); font-size: 12px; z-index: 1000; } @@ -116,242 +116,227 @@ {% endblock %} {% block body %} -
-
-
-

- Mon Commerce OSM -

-

- Bonjour, ce site permet de modifier les informations de votre commerce sur OpenStreetMap afin de - gagner en visibilité sur des milliers de sites web à la fois en une minute, c'est gratuit et sans - engagement. -
Nous sommes bénévoles dans une association à but non lucratif. -
Nous vous enverrons un lien unique pour cela par email, et si vous en avez besoin, nous pouvons - vous aider. -

-
-
- - + +
+
+
+ +
+

+ OpenStreetMap Logo + Mon Commerce OSM +

+

+ Améliorez la visibilité de votre commerce sur des milliers de sites web et applications en + quelques minutes. +

+
+ - -
- -
- - -
-
- - - {% if citiesForMap is not empty %} -
-
-
-
-
-

Carte des villes disponibles

-

Cliquez sur un marqueur pour voir les statistiques de la ville

-
- - Ajouter ma ville - -
-
-
-
-
-
-
- Complétion > 80% -
-
-
- Complétion 50-80% -
-
-
- Complétion < 50% -
-
-
-
-
-
-
- {% endif %} - - - -
-
-
-
-

Villes disponibles

-

Visualisez un tableau de bord de la complétion des commerces et autres lieux d'intérêt pour votre - ville grâce à OpenStreetMap

+
+
+ +
+
+

Pourquoi mettre à jour vos informations sur OpenStreetMap ?

+

Un seul ajout, une visibilité sur des milliers de plateformes

- {% set sorted_stats = stats|sort((a, b) => a.zone <=> b.zone) %} - {% for stat in sorted_stats %} +
+
+
+
+ +
+

Visibilité Maximale

+

Vos informations apparaîtront sur Google Maps, Apple Plans, Facebook et des + centaines d'autres applications.

+
+
+
- {% if stat.zone != 'undefined' and stat.zone matches '/^\\d+$/' %} - -
- {{ stat.zone }} - {{ stat.name }} +
+
+
+
+
-
- {{ stat.placesCount }} lieux - {{ stat.completionPercent }}% +

Rapide et Simple

+

Mettez à jour vos horaires, contacts et services en quelques minutes + seulement.

+
+
+
+ +
+
+
+
+
-
- {% endif %} - {% endfor %} - {% include 'public/labourage-form.html.twig' %} +

100% Gratuit

+

Service entièrement gratuit et sans engagement, fourni par une association + à but non lucratif.

+
+
+
+
+ + +
+
+
+
+

+ Rechercher votre commerce +

+
+
+

+ Entrez le nom de votre commerce et sa ville pour commencer à mettre à jour vos informations. +

+
+ +
+ + + + +
+
+ Exemple: Café de la Place, Lyon +
+
+ +
+ +
+
+ +
+ + + + +
+
+ +
+
+
+
+
+
+ + +
+
+

Comment ça marche ?

+

Un processus simple en 3 étapes

+
+ +
+
+
+
+
+ 1 +
+

Recherchez votre commerce

+
+

Entrez le nom de votre établissement et sa localité pour le retrouver dans + notre base de données.

+
+
+
+ +
+
+
+
+
+ 2 +
+

Recevez votre lien unique

+
+

Nous vous envoyons par email un lien sécurisé pour accéder à votre fiche + commerce.

+
+
+
+ +
+
+
+
+
+ 3 +
+

Mettez à jour vos informations

+
+

Modifiez facilement vos horaires, contacts, services et autres informations + importantes.

+
+
+
+
+ + +
+
+
+
+

+ Besoin d'aide ? +

+

+ Notre équipe de bénévoles est là pour vous accompagner dans la mise à jour de vos + informations. +

+ + Contactez-nous + +
+
+
{% endblock %} {% block javascripts %} {{ parent() }} - - {# #} - - {% endblock %} - diff --git a/templates/public/nav.html.twig b/templates/public/nav.html.twig index d384642b..93ce8330 100644 --- a/templates/public/nav.html.twig +++ b/templates/public/nav.html.twig @@ -17,12 +17,14 @@
- \ No newline at end of file + diff --git a/templates/public/rss/demandes.xml.twig b/templates/public/rss/demandes.xml.twig new file mode 100644 index 00000000..4d68377d --- /dev/null +++ b/templates/public/rss/demandes.xml.twig @@ -0,0 +1,35 @@ + + + + Mon Commerce OSM - Demandes + https://{{ base_url }} + Flux RSS des demandes de modification de commerces sur OpenStreetMap + fr-fr + {{ "now"|date("D, d M Y H:i:s O") }} + + + {% for demande in demandes %} + + {{ demande.query }} + https://{{ base_url }}{{ path('app_admin_demande_edit', {'id': demande.id}) }} + https://{{ base_url }}{{ path('app_admin_demande_edit', {'id': demande.id}) }} + {{ demande.createdAt|date("D, d M Y H:i:s O") }} + + Nom du commerce: {{ demande.query }}

+ {% if demande.email %} +

Email: {{ demande.email }}

+ {% endif %} +

Statut: {{ demande.status }}

+ {% if demande.insee %} +

Code INSEE: {{ demande.insee }}

+ {% endif %} + {% if demande.lastContactAttempt %} +

Dernière tentative de contact: {{ demande.lastContactAttempt|date("d/m/Y H:i:s") }}

+ {% endif %} + ]]> +
+
+ {% endfor %} +
+
\ No newline at end of file diff --git a/test_city_followup.php b/test_city_followup.php new file mode 100644 index 00000000..4d53eeea --- /dev/null +++ b/test_city_followup.php @@ -0,0 +1,48 @@ + '75056', // Example INSEE code for Paris + 'measure_label' => 'test_measure', + 'measure_value' => 42.5 +]; + +// Function to make a POST request +function makePostRequest($url, $data) { + $options = [ + 'http' => [ + 'header' => "Content-type: application/x-www-form-urlencoded\r\n", + 'method' => 'POST', + 'content' => http_build_query($data) + ] + ]; + $context = stream_context_create($options); + $result = file_get_contents($url, false, $context); + + if ($result === FALSE) { + echo "Error making request\n"; + return null; + } + + return $result; +} + +// First request should succeed +echo "Making first request...\n"; +$response = makePostRequest($url, $data); +echo "Response: " . $response . "\n\n"; + +// Second request within an hour should fail +echo "Making second request (should fail due to 1-hour limit)...\n"; +$response = makePostRequest($url, $data); +echo "Response: " . $response . "\n\n"; + +// Try with a different measure label (should succeed) +echo "Making request with different measure label...\n"; +$data['measure_label'] = 'another_test_measure'; +$response = makePostRequest($url, $data); +echo "Response: " . $response . "\n"; \ No newline at end of file