From 2e459122b5b2fae3945afdd4f310ac9d90cf02e7 Mon Sep 17 00:00:00 2001 From: Tykayn Date: Wed, 16 Jul 2025 17:31:15 +0200 Subject: [PATCH] =?UTF-8?q?menu=20lat=C3=A9ral=20ville?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/city-sidebar.css | 84 +++++ .../CreateStatsFromDemandesCommand.php | 103 ++++++ src/Command/LinkDemandesPlacesCommand.php | 202 ++++++++++++ src/Controller/PublicController.php | 95 ++++++ templates/admin/_city_sidebar.html.twig | 73 +++++ templates/admin/stats.html.twig | 175 +++++----- templates/public/city_demandes.html.twig | 118 +++++++ templates/public/home.html.twig | 6 +- templates/public/rss/city_demandes.xml.twig | 33 ++ templates/public/rss/city_themes.xml.twig | 45 +++ templates/public/stats_evolutions.html.twig | 310 +++++++++--------- 11 files changed, 1008 insertions(+), 236 deletions(-) create mode 100644 public/css/city-sidebar.css create mode 100644 src/Command/CreateStatsFromDemandesCommand.php create mode 100644 src/Command/LinkDemandesPlacesCommand.php create mode 100644 templates/admin/_city_sidebar.html.twig create mode 100644 templates/public/city_demandes.html.twig create mode 100644 templates/public/rss/city_demandes.xml.twig create mode 100644 templates/public/rss/city_themes.xml.twig diff --git a/public/css/city-sidebar.css b/public/css/city-sidebar.css new file mode 100644 index 00000000..05e995e1 --- /dev/null +++ b/public/css/city-sidebar.css @@ -0,0 +1,84 @@ +/* Styles pour la sidebar */ +.city-sidebar { + background-color: #f8f9fa; + border-right: 1px solid #dee2e6; + padding: 1rem; + height: 100%; + position: fixed; + top: 0; + left: 0; + width: 100%; + overflow-y: auto; + z-index: 1000; +} + +/* Desktop styles */ +@media (min-width: 768px) { + .city-sidebar { + width: 25%; + max-width: 280px; + } + .main-content { + margin-left: 25%; + width: 75%; + padding-top: 20px; + } + .main-header { + margin-left: 25%; + width: 75%; + z-index: 1001; + } +} + +/* Mobile styles */ +@media (max-width: 767px) { + .city-sidebar { + position: relative; + width: 100%; + max-height: none; + } + .main-content { + margin-left: 0; + width: 100%; + } + .main-header { + margin-left: 0; + width: 100%; + } +} + +.city-sidebar .nav-link { + color: #495057; + border-radius: 0.25rem; + padding: 0.5rem 1rem; + margin-bottom: 0.25rem; +} + +.city-sidebar .nav-link:hover { + background-color: #e9ecef; +} + +.city-sidebar .nav-link.active { + background-color: #0d6efd; + color: white; +} + +.city-sidebar .nav-link i { + width: 1.5rem; + text-align: center; + margin-right: 0.5rem; +} + +.city-sidebar .sidebar-heading { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1rem; + color: #6c757d; + margin-top: 1rem; + margin-bottom: 0.5rem; + padding-left: 0.5rem; +} + +.section-anchor { + scroll-margin-top: 2rem; +} diff --git a/src/Command/CreateStatsFromDemandesCommand.php b/src/Command/CreateStatsFromDemandesCommand.php new file mode 100644 index 00000000..93558c85 --- /dev/null +++ b/src/Command/CreateStatsFromDemandesCommand.php @@ -0,0 +1,103 @@ +entityManager = $entityManager; + $this->demandeRepository = $demandeRepository; + $this->statsRepository = $statsRepository; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Creating Stats objects for cities with Demandes but no Stats'); + + // Find all Demandes with INSEE codes + $demandesWithInsee = $this->demandeRepository->createQueryBuilder('d') + ->where('d.insee IS NOT NULL') + ->getQuery() + ->getResult(); + + if (empty($demandesWithInsee)) { + $io->warning('No Demandes with INSEE codes found.'); + return Command::SUCCESS; + } + + $io->info(sprintf('Found %d Demandes with INSEE codes.', count($demandesWithInsee))); + + // Group Demandes by INSEE code + $demandesByInsee = []; + /** @var Demande $demande */ + foreach ($demandesWithInsee as $demande) { + $insee = $demande->getInsee(); + if (!isset($demandesByInsee[$insee])) { + $demandesByInsee[$insee] = []; + } + $demandesByInsee[$insee][] = $demande; + } + + $io->info(sprintf('Found %d unique INSEE codes.', count($demandesByInsee))); + + // Check which INSEE codes don't have Stats objects + $newStatsCount = 0; + foreach ($demandesByInsee as $insee => $demandes) { + $stats = $this->statsRepository->findOneBy(['zone' => $insee]); + if ($stats === null) { + // Create a new Stats object for this INSEE code + $stats = new Stats(); + $stats->setZone((string) $insee); + + // Try to set the city name from the first Demande + $firstDemande = $demandes[0]; + if ($firstDemande->getQuery()) { + // Use the query as a fallback name (will be updated during labourage) + $stats->setName($firstDemande->getQuery()); + } + + $stats->setDateCreated(new \DateTime()); + $stats->setDateLabourageRequested(new \DateTime()); + + $this->entityManager->persist($stats); + $newStatsCount++; + + $io->text(sprintf('Created Stats for INSEE code %s', $insee)); + } + } + + if ($newStatsCount > 0) { + $this->entityManager->flush(); + $io->success(sprintf('Created %d new Stats objects.', $newStatsCount)); + } else { + $io->info('No new Stats objects needed to be created.'); + } + + return Command::SUCCESS; + } +} \ No newline at end of file diff --git a/src/Command/LinkDemandesPlacesCommand.php b/src/Command/LinkDemandesPlacesCommand.php new file mode 100644 index 00000000..9d2e2d0d --- /dev/null +++ b/src/Command/LinkDemandesPlacesCommand.php @@ -0,0 +1,202 @@ +entityManager = $entityManager; + $this->demandeRepository = $demandeRepository; + $this->placeRepository = $placeRepository; + } + + protected function configure(): void + { + $this + ->addOption('threshold', null, InputOption::VALUE_REQUIRED, 'Similarity threshold (0-100)', 70) + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show matches without linking'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Linking Demandes to Places based on name similarity'); + + $threshold = (int) $input->getOption('threshold'); + $dryRun = $input->getOption('dry-run'); + + if ($threshold < 0 || $threshold > 100) { + $io->error('Threshold must be between 0 and 100'); + return Command::FAILURE; + } + + // Find all Demandes without linked Places + $demandesWithoutPlace = $this->demandeRepository->createQueryBuilder('d') + ->where('d.place IS NULL') + ->getQuery() + ->getResult(); + + if (empty($demandesWithoutPlace)) { + $io->warning('No Demandes without linked Places found.'); + return Command::SUCCESS; + } + + $io->info(sprintf('Found %d Demandes without linked Places.', count($demandesWithoutPlace))); + + // Process each Demande + $linkedCount = 0; + /** @var Demande $demande */ + foreach ($demandesWithoutPlace as $demande) { + $query = $demande->getQuery(); + $insee = $demande->getInsee(); + + if (empty($query)) { + continue; + } + + // Find Places with similar names + $places = $this->findSimilarPlaces($query, $insee); + if (empty($places)) { + continue; + } + + // Find the best match + $bestMatch = null; + $bestSimilarity = 0; + foreach ($places as $place) { + $similarity = $this->calculateSimilarity($query, $place->getName()); + if ($similarity > $bestSimilarity) { + $bestSimilarity = $similarity; + $bestMatch = $place; + } + } + + // If similarity is above threshold, link the Demande to the Place + if ($bestMatch && $bestSimilarity >= $threshold) { + $io->text(sprintf( + 'Match found: "%s" (Demande) -> "%s" (Place) with similarity %d%%', + $query, + $bestMatch->getName(), + $bestSimilarity + )); + + if (!$dryRun) { + $demande->setPlace($bestMatch); + $demande->setStatus('linked_to_place'); + $this->entityManager->persist($demande); + $linkedCount++; + } + } + } + + if (!$dryRun && $linkedCount > 0) { + $this->entityManager->flush(); + $io->success(sprintf('Linked %d Demandes to Places.', $linkedCount)); + } elseif ($dryRun) { + $io->info('Dry run completed. No changes were made.'); + } else { + $io->info('No Demandes were linked to Places.'); + } + + return Command::SUCCESS; + } + + /** + * Find Places with names similar to the query + */ + private function findSimilarPlaces(string $query, ?int $insee): array + { + $queryBuilder = $this->placeRepository->createQueryBuilder('p') + ->where('p.name IS NOT NULL'); + + // If INSEE code is available, filter by Stats zone + if ($insee !== null) { + $queryBuilder + ->join('p.stats', 's') + ->andWhere('s.zone = :insee') + ->setParameter('insee', (string) $insee); + } + + // Use LIKE for initial filtering to reduce the number of results + $queryBuilder + ->andWhere('p.name LIKE :query') + ->setParameter('query', '%' . $this->sanitizeForLike($query) . '%'); + + return $queryBuilder->getQuery()->getResult(); + } + + /** + * Calculate similarity between two strings (0-100) + */ + private function calculateSimilarity(string $str1, string $str2): int + { + // Normalize strings for comparison + $str1 = $this->normalizeString($str1); + $str2 = $this->normalizeString($str2); + + // If either string is empty after normalization, return 0 + if (empty($str1) || empty($str2)) { + return 0; + } + + // Calculate Levenshtein distance + $levenshtein = levenshtein($str1, $str2); + $maxLength = max(strlen($str1), strlen($str2)); + + // Convert to similarity percentage (0-100) + $similarity = (1 - $levenshtein / $maxLength) * 100; + + return (int) $similarity; + } + + /** + * Normalize a string for comparison + */ + private function normalizeString(string $str): string + { + // Convert to lowercase + $str = mb_strtolower($str); + + // Remove accents + $str = transliterator_transliterate('Any-Latin; Latin-ASCII', $str); + + // Remove special characters and extra spaces + $str = preg_replace('/[^a-z0-9\s]/', '', $str); + $str = preg_replace('/\s+/', ' ', $str); + + return trim($str); + } + + /** + * Sanitize a string for use in LIKE queries + */ + private function sanitizeForLike(string $str): string + { + return str_replace(['%', '_'], ['\%', '\_'], $str); + } +} \ No newline at end of file diff --git a/src/Controller/PublicController.php b/src/Controller/PublicController.php index 6f0bf384..5e3ab5fd 100644 --- a/src/Controller/PublicController.php +++ b/src/Controller/PublicController.php @@ -1007,6 +1007,101 @@ class PublicController extends AbstractController return $response; } + #[Route('/rss/city/{insee_code}/demandes', name: 'app_public_rss_city_demandes')] + public function rssCityDemandes(string $insee_code): Response + { + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + if (!$stats) { + throw $this->createNotFoundException('Ville non trouvée'); + } + + // Récupérer les demandes pour cette ville + $demandes = $this->entityManager->getRepository(Demande::class) + ->createQueryBuilder('d') + ->where('d.insee = :insee') + ->setParameter('insee', $insee_code) + ->orderBy('d.createdAt', 'DESC') + ->getQuery() + ->getResult(); + + $content = $this->renderView('public/rss/city_demandes.xml.twig', [ + 'demandes' => $demandes, + 'city' => $stats, + 'base_url' => $this->getParameter('router.request_context.host'), + ]); + + $response = new Response($content); + $response->headers->set('Content-Type', 'application/rss+xml'); + + return $response; + } + + #[Route('/rss/city/{insee_code}/themes', name: 'app_public_rss_city_themes')] + public function rssCityThemes(string $insee_code): Response + { + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + if (!$stats) { + throw $this->createNotFoundException('Ville non trouvée'); + } + + // Récupérer les changements thématiques pour cette ville + $followups = $stats->getCityFollowUps(); + $themeChanges = []; + + foreach ($followups as $followup) { + $name = $followup->getName(); + if (str_ends_with($name, '_count')) { + $type = substr($name, 0, -6); + if (!isset($themeChanges[$type])) { + $themeChanges[$type] = []; + } + $themeChanges[$type][] = $followup; + } + } + + // Trier les changements par date pour chaque thème + foreach ($themeChanges as &$changes) { + usort($changes, function($a, $b) { + return $b->getDate() <=> $a->getDate(); + }); + } + + $content = $this->renderView('public/rss/city_themes.xml.twig', [ + 'themeChanges' => $themeChanges, + 'city' => $stats, + 'base_url' => $this->getParameter('router.request_context.host'), + 'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(), + ]); + + $response = new Response($content); + $response->headers->set('Content-Type', 'application/rss+xml'); + + return $response; + } + + #[Route('/city/{insee_code}/demandes', name: 'app_public_city_demandes')] + public function cityDemandes(string $insee_code): Response + { + $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); + if (!$stats) { + throw $this->createNotFoundException('Ville non trouvée'); + } + + // Récupérer les demandes pour cette ville + $demandes = $this->entityManager->getRepository(Demande::class) + ->createQueryBuilder('d') + ->where('d.insee = :insee') + ->setParameter('insee', $insee_code) + ->orderBy('d.createdAt', 'DESC') + ->getQuery() + ->getResult(); + + return $this->render('public/city_demandes.html.twig', [ + 'demandes' => $demandes, + 'city' => $stats, + ]); + } + #[Route('/cities', name: 'app_public_cities')] public function cities(): Response { diff --git a/templates/admin/_city_sidebar.html.twig b/templates/admin/_city_sidebar.html.twig new file mode 100644 index 00000000..3ea531a6 --- /dev/null +++ b/templates/admin/_city_sidebar.html.twig @@ -0,0 +1,73 @@ +{# City Sidebar Template #} +
+
{{ stats.name }}
+

+ {{ stats.getCompletionPercent() }}% complété +

+ + + + + + + + + + + + + + + + + + Labourer (sans nettoyage) + +
\ No newline at end of file diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index 3789db73..9efc5441 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -6,6 +6,7 @@ {% block stylesheets %} {{ parent() }} +