motocultrice->get_osm_object_data($type, $id); // Récupérer le code postal depuis les tags, sinon mettre -1 $zipCode = isset($data['tags_converted']['addr:postcode']) ? (int)$data['tags_converted']['addr:postcode'] : -1; $place_name = $data['tags_converted']['name'] ?? 'Commerce'; // Vérifier si une Place existe déjà avec le même osm_kind et osmId $existingPlace = $this->entityManager->getRepository(Place::class)->findOneBy([ 'osm_kind' => $type, 'osmId' => $id ]); if ($existingPlace) { // Mettre à jour l'email de la Place existante $existingPlace->setEmail($email)->setLastContactAttemptDate(new \DateTime()); if ($zipCode != -1) { $existingPlace->setZipCode($zipCode); } $this->entityManager->persist($existingPlace); $this->entityManager->flush(); $debug = ''; if ($this->getParameter('kernel.environment') !== 'prod') { $debug = 'Voici votre lien unique de modification: cliquez ici pour accéder au formulaire de modification"'; } $this->addFlash('success', 'L\'email a été mis à jour. Un email vous sera envoyé avec le lien de modification. ' . $debug); } else { // Créer une nouvelle entité Place $place = new Place(); $place->setEmail($email) ->setOsmId($id) ->setOsmKind($type) ->setAskedHumainsSupport(false) ->setOptedOut(false) ->setDead(false) ->setNote('') ->setModifiedDate(new \DateTime()) ->setZipCode($zipCode) ->setPlaceCount(0) ->setMainTag($this->motocultrice->find_main_tag($data['tags_converted']) ?? '') ->setStreet($this->motocultrice->find_street($data['tags_converted']) ?? '') ->setHousenumber($this->motocultrice->find_housenumber($data['tags_converted']) ?? '') ->setLastContactAttemptDate(new \DateTime()) ->setUuidForUrl(uniqid()); $this->entityManager->persist($place); $this->entityManager->flush(); $debug = ''; if ($this->getParameter('kernel.environment') !== 'prod') { $debug = 'Bonjour, nous sommes des bénévoles d\'OpenStreetMap France et nous vous proposons de modifier les informations de votre commerce. Voici votre lien unique de modification: ' . $this->generateUrl('app_public_edit', [ 'zipcode' => $zipCode, 'name' => $place_name, 'uuid' => $place->getUuidForUrl() ], true); } $this->addFlash('success', 'Un email vous sera envoyé avec le lien de modification. ' . $debug); } // Envoyer l'email $destinataire = $this->getParameter('kernel.environment') === 'prod' ? $email : 'contact+essai_osm_commerce@cipherbliss.com'; $message = (new Email()) ->from('contact@osm-commerce.fr') ->to($destinataire) ->subject('Votre lien de modification OpenStreetMap') ->text('Bonjour, nous sommes des bénévoles d\'OpenStreetMap France et nous vous proposons de modifier les informations de votre commerce. Voici votre lien unique de modification: ' . $this->generateUrl('app_public_edit', [ 'zipcode' => $zipCode, 'name' => $place_name, 'uuid' => $existingPlace ? $existingPlace->getUuidForUrl() : $place->getUuidForUrl() ], true)); $this->mailer->send($message); 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($data['osmId']); } // Check if a Place exists with the same OSM ID and type $place = null; if ($demande->getOsmId() && $demande->getOsmObjectType()) { $existingPlace = $this->entityManager->getRepository(Place::class)->findOneBy([ 'osm_kind' => $demande->getOsmObjectType(), 'osmId' => $demande->getOsmId() ]); if ($existingPlace) { // Link the Place UUID to the Demande $demande->setPlaceUuid($existingPlace->getUuidForUrl()); $demande->setPlace($existingPlace); $place = $existingPlace; } else { // Create a new Place if one doesn't exist $place = new Place(); $place->setOsmId((string)$demande->getOsmId()); $place->setOsmKind($demande->getOsmObjectType()); // Get OSM data from Overpass API $commerce_overpass = $this->motocultrice->get_osm_object_data($demande->getOsmObjectType(), $demande->getOsmId()); if ($commerce_overpass) { // Update the Place with OSM data $place->update_place_from_overpass_data($commerce_overpass); // Link the Place to the Demande $demande->setPlaceUuid($place->getUuidForUrl()); $demande->setPlace($place); // Persist the Place $this->entityManager->persist($place); } } // Link the Place to a Stat object using the INSEE code if ($place && $demande->getInsee()) { $stats = $place->getStats(); if (!$stats) { $stats_exist = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $demande->getInsee()]); if ($stats_exist) { $stats = $stats_exist; } else { // var_dump('création d\'objet stats '); $stats = new Stats(); $zipcode = (string)$demande->getInsee(); $stats->setZone($zipcode); $stats->setKind('user'); // Set the kind to 'user' as it's created from a user request $place->setZipCode($zipcode); $this->entityManager->persist($stats); } $stats->addPlace($place); $place->setStats($stats); } } } if (!$place->getUuidForUrl()) { $place->setUuidForUrl(uniqid()); } if (!$place->getZipCode()) { $place->setZipCode("00000"); } $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 { $stats = $this->entityManager->getRepository(Stats::class)->findAll(); // 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[] = [ 'name' => $cityName, 'zone' => $stat->getZone(), 'coordinates' => [ 'lat' => (float)$stat->getLat(), 'lon' => (float)$stat->getLon() ], 'placesCount' => $stat->getPlacesCount(), 'completionPercent' => $stat->getCompletionPercent(), 'population' => $stat->getPopulation(), 'url' => $this->generateUrl('app_admin_stats', ['insee_code' => $stat->getZone()]) ]; } } } return $this->render('public/home.html.twig', [ 'controller_name' => 'PublicController', 'stats' => $stats, 'citiesForMap' => $citiesForMap, 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null ]); } /** * Récupère les coordonnées d'une ville via l'API Nominatim */ private function getCityCoordinates(string $cityName, string $inseeCode): ?array { // 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 [ 'lat' => (float)$data[0]['lat'], 'lon' => (float)$data[0]['lon'] ]; } else { error_log("DEBUG: Aucune coordonnée trouvée pour $cityName ($inseeCode)"); } } catch (\Exception $e) { error_log("DEBUG: Exception lors de la récupération des coordonnées pour $cityName ($inseeCode): " . $e->getMessage()); } return null; } #[Route('/edit/{zipcode}/{name}/{uuid}', name: 'app_public_edit')] public function edit_with_uuid($zipcode, $name, $uuid): Response { $this->actionLogger->log('edit_place', [ 'zipcode' => $zipcode, 'name' => $name, 'uuid' => $uuid, ]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]); if (!$place) { $this->addFlash('warning', 'Ce lien de modification n\'existe pas.' . $uuid); return $this->redirectToRoute('app_public_index'); } if ($place->getOsmKind() === 'relation') { $this->addFlash('warning', 'Les objets OSM de type "relation" ne sont pas gérés dans cet outil.'); return $this->redirectToRoute('app_public_index'); } // récupérer les tags de base $base_tags = $this->motocultrice->base_tags; $base_tags = array_fill_keys($base_tags, ''); $commerce_overpass = $this->motocultrice->get_osm_object_data($place->getOsmKind(), $place->getOsmId()); // Mettre à jour la Place à partir des infos Overpass $place->update_place_from_overpass_data($commerce_overpass); $this->entityManager->persist($place); $this->entityManager->flush(); // Fusionner les tags de base avec les tags existants $commerce_overpass['tags_converted'] = array_merge($base_tags, $commerce_overpass['tags_converted']); // Trier les tags par ordre alphabétique des clés ksort($commerce_overpass['tags_converted']); $place->setDisplayedDate(new \DateTime()); $this->entityManager->persist($place); $this->entityManager->flush(); return $this->render('public/edit.html.twig', [ 'commerce_overpass' => $commerce_overpass, 'name' => $name, 'commerce' => $place, 'zone' => $zipcode, 'zipcode' => $zipcode, 'completion_percentage' => $place->getCompletionPercentage(), 'hide_filled_inputs' => $this->hide_filled_inputs, 'excluded_tags_to_render' => $this->motocultrice->excluded_tags_to_render, 'osm_kind' => $place->getOsmKind(), "mapbox_token" => $_ENV['MAPBOX_TOKEN'], "maptiler_token" => $_ENV['MAPTILER_TOKEN'], ]); } #[Route('/dashboard', name: 'app_public_dashboard')] public function dashboard(): Response { $this->actionLogger->log('dashboard', []); $stats_repo = $this->entityManager->getRepository(Stats::class)->findAll(); $stats_for_chart = []; foreach ($stats_repo as $stat) { if ($stat->getPlacesCount() > 0 && $stat->getName() !== null && $stat->getPopulation() > 0) { $stats_for_chart[] = [ 'name' => $stat->getName(), 'placesCount' => $stat->getPlacesCount(), 'completionPercent' => $stat->getCompletionPercent(), 'population' => $stat->getPopulation(), 'zone' => $stat->getZone(), 'osmDataDateAvg' => $stat->getOsmDataDateAvg() ? $stat->getOsmDataDateAvg()->format('Y-m-d') : null, ]; } } // Compter le nombre total de lieux $placesCount = $this->entityManager->getRepository(Place::class)->count([]); return $this->render('public/dashboard.html.twig', [ 'controller_name' => 'PublicController', 'mapbox_token' => $_ENV['MAPBOX_TOKEN'] ?? null, 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, 'stats' => json_encode($stats_for_chart), 'stats_list' => $stats_repo, 'places_count' => $placesCount, ]); } #[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'); $statsGlobal->setKind('request'); // Set the kind to 'request' as it's a system-generated stat $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}/{insee_code}', name: 'app_public_submit')] public function submit($osm_object_id, $version, $changesetID,$insee_code): Response { $this->actionLogger->log('submit_object', [ 'osm_id' => $osm_object_id, 'version' => $version, 'changesetID' => $changesetID ]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['osmId' => $osm_object_id]); if (!$place) { $this->addFlash('warning', 'Ce commerce n\'existe pas.'); return $this->redirectToRoute('app_public_index'); } $stats = $place->getStats(); $stat_zone = $stats->getZone(); // var_dump('stats object:', $stats->getZone()); // Récupérer les données POST $request = Request::createFromGlobals(); $status = null; $exception = false; $exception_message = null; $osm_kind = 'node'; // Vérifier si des données ont été soumises if ($request->isMethod('POST')) { $status = "non modifié"; $osm_kind = $request->request->get('osm_kind', 'node'); // Récupérer tous les tags du formulaire $tags = []; $request_post = $request->request->all(); // var_dump($request_post); $request_post = $this->motocultrice->map_post_values($request_post); $request_post = $request_post ?? []; // Log temporaire pour debug POST file_put_contents('/tmp/debug_post.txt', print_r($request_post, true)); // Debug visuel immédiat $excluded_post_fields = []; foreach ($request_post as $key => $value) { if (strpos($key, 'commerce_tag_value__') === 0) { $tagKey = str_replace('commerce_tag_value__', '', $key); // Corriger les underscores convertis par PHP en deux-points pour les tags OSM // PHP convertit automatiquement les deux-points en underscores dans les noms de champs POST // On restaure les deux-points pour les tags qui commencent par contact_ ou addr_ if (strpos($tagKey, 'contact_') === 0) { $tagKey = preg_replace('/^contact_/', 'contact:', $tagKey); } elseif (strpos($tagKey, 'addr_') === 0) { $tagKey = preg_replace('/^addr_/', 'addr:', $tagKey); } // On ajoute la clé même si la valeur est vide (pour affichage suppression) $tags[$tagKey] = trim($value); } else { $excluded_post_fields[] = $key; } } // Récupérer les tags Overpass avant modification $currentObjectData = $this->motocultrice->get_osm_object_data($osm_kind, $osm_object_id); $tags_before_modif = $currentObjectData['tags_converted'] ?? []; $tags_after_modif = $tags; // Récupérer le token OSM depuis les variables d'environnement $osm_api_token = $_ENV['APP_OSM_BEARER']; try { $client = new Client(); // 1. Créer un nouveau changeset $changesetXml = new \SimpleXMLElement(''); $changeset = $changesetXml->addChild('changeset'); $tag = $changeset->addChild('tag'); $tag->addAttribute('k', 'created_by'); $tag->addAttribute('v', 'OSM Mon Commerce Web Editor'); $tag = $changeset->addChild('tag'); $tag->addAttribute('k', 'comment'); $tag->addAttribute('v', 'Modification dans #MonCommerceOSM'); $changesetResponse = $client->put('https://api.openstreetmap.org/api/0.6/changeset/create', [ 'body' => $changesetXml->asXML(), 'headers' => [ 'Authorization' => 'Bearer ' . $osm_api_token, 'Content-Type' => 'application/xml' ] ]); $newChangesetId = $changesetResponse->getBody()->getContents(); // 2. Modifier l'objet avec le nouveau changeset $xml = new \SimpleXMLElement(''); $object = $xml->addChild($osm_kind); $object->addAttribute('id', $osm_object_id); $object->addAttribute('version', $version); $object->addAttribute('changeset', $newChangesetId); // Ajouter les coordonnées pour les nodes if ($osm_kind === 'node') { if (!isset($currentObjectData['@attributes']['lat']) || !isset($currentObjectData['@attributes']['lon'])) { throw new \Exception("Impossible de récupérer les coordonnées du nœud"); } $object->addAttribute('lat', $currentObjectData['@attributes']['lat']); $object->addAttribute('lon', $currentObjectData['@attributes']['lon']); } // Ajouter les tags foreach ($tags as $key => $value) { if (!empty($key) && !empty($value)) { $tag = $object->addChild('tag'); $tag->addAttribute('k', htmlspecialchars($key, ENT_XML1)); $tag->addAttribute('v', htmlspecialchars($value, ENT_XML1)); } } // Debug du XML généré $xmlString = $xml->asXML(); $response = $client->put("https://api.openstreetmap.org/api/0.6/{$osm_kind}/" . $osm_object_id, [ 'body' => $xmlString, 'headers' => [ 'Authorization' => 'Bearer ' . $osm_api_token, 'Content-Type' => 'application/xml' ] ]); // 3. Fermer le changeset $client->put('https://api.openstreetmap.org/api/0.6/changeset/' . $newChangesetId . '/close', [ 'headers' => [ 'Authorization' => 'Bearer ' . $osm_api_token ] ]); if ($response->getStatusCode() === 200) { $status = "Les tags ont été mis à jour avec succès"; } else { $status = "Erreur lors de la mise à jour des tags"; $this->actionLogger->log('ERROR_submit_object', [ 'osm_id' => $osm_object_id, 'version' => $version, 'changesetID' => $changesetID, 'body_sent' => $xmlString, 'response' => $response->getBody()->getContents(), ]); } } catch (\Exception $e) { $status = "Erreur lors de la communication avec l'API OSM: " . $e->getMessage(); $exception = true; $exception_message = $e->getMessage(); // On ne suppose plus la présence de getResponse (évite l'erreur linter) $this->actionLogger->log('ERROR_submit_object_exception', [ 'osm_id' => $osm_object_id ?? null, 'version' => $version ?? null, 'changesetID' => $changesetID ?? null, 'body_sent' => $xmlString ?? null, 'exception_message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); } } // après envoi on récupère les données $commerce_overpass = $this->motocultrice->get_osm_object_data($osm_kind, $osm_object_id); $place->update_place_from_overpass_data($commerce_overpass); $this->entityManager->persist($place); // $this->entityManager->flush(); // $this->entityManager->clear(); $stats = $place->getStats(); // if (!$stats) { // // When modifying a Place, only use existing Stats objects, don't create new ones // $stats_exist = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); // if ($stats_exist) { // $stats = $stats_exist; // } else { // // If no Stats object exists for this INSEE code, we can't proceed // $this->addFlash('warning', 'Impossible de modifier ce commerce car aucune statistique n\'existe pour cette zone.'); // return $this->redirectToRoute('app_public_index'); // } // } $stats->addPlace($place); // $place->setStats($stats); $place->setModifiedDate(new \DateTime()); $stats->computeCompletionPercent(); // $this->entityManager->persist($stats); $this->entityManager->persist($place); $this->entityManager->flush(); $this->entityManager->clear(); return $this->render('public/view.html.twig', [ 'controller_name' => 'PublicController', 'commerce' => $commerce_overpass, 'commerce_overpass' => $commerce_overpass, 'place' => $place, 'status' => $status, 'insee_code' => $insee_code, 'stat_zone' => $stat_zone, 'exception' => $exception, 'exception_message' => $exception_message, 'mapbox_token' => $_ENV['MAPBOX_TOKEN'], 'maptiler_token' => $_ENV['MAPTILER_TOKEN'], 'hide_filled_inputs' => false, 'excluded_tags_to_render' => $this->motocultrice->excluded_tags_to_render, 'tags_before_modif' => $tags_before_modif ?? null, 'tags_after_modif' => $tags_after_modif ?? null, 'excluded_post_fields' => $excluded_post_fields ?? null, ]); } #[Route('/request_email_to_modify/{osm_object_id}', name: 'app_public_request_email')] public function request_email($osm_object_id, Request $request): Response { $this->actionLogger->log('request_email_to_modify', [ 'osm_id' => $osm_object_id, ]); if ($request->isMethod('POST')) { $email = $request->request->get('email'); try { // TODO: Implémenter l'envoi réel du mail $this->addFlash( 'success', 'Un email vous a été envoyé avec les instructions pour modifier ce lieu.' ); } catch (\Exception $e) { $this->actionLogger->log('ERROR_request_email_to_modify', [ 'osm_id' => $osm_object_id, 'exception_message' => $e->getMessage(), ]); $this->addFlash( 'error', 'Une erreur est survenue lors de l\'envoi de l\'email. Veuillez réessayer plus tard.' ); } return $this->redirectToRoute('app_public_index'); } // TODO envoyer un email return $this->render('public/request_email.html.twig', [ 'controller_name' => 'PublicController', 'commerce_id' => $osm_object_id, ]); } #route pour signaler que le commerce est fermé #[Route('/closed_commerce/{osm_object_id}', name: 'app_public_closed_commerce')] public function closed_commerce($osm_object_id): Response { $this->actionLogger->log('closed_commerce', [ 'osm_id' => $osm_object_id, ]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_id' => $osm_object_id]); if (!$place) { $this->addFlash('warning', 'Ce commerce n\'existe pas.'); return $this->redirectToRoute('app_public_index'); } $place->setClosed(true); $place->setDead(true); $this->entityManager->flush(); return $this->render('public/closed_commerce.html.twig', [ 'controller_name' => 'PublicController', ]); } #[Route('/closed_commerces', name: 'app_public_closed_commerces')] public function closedCommerces(): Response { // Récupérer tous les commerces marqués comme fermés $closedPlaces = $this->entityManager->getRepository(Place::class)->findBy(['dead' => true]); return $this->render('public/closed_commerces.html.twig', [ 'controller_name' => 'PublicController', 'closed_places' => $closedPlaces ]); } #[Route('/places_with_note', name: 'app_public_places_with_note')] public function places_with_note(): Response { // Récupérer tous les commerces ayant une note $places = $this->entityManager->getRepository(Place::class)->findBy(['has_note' => true]); return $this->render('public/places_with_note.html.twig', [ 'controller_name' => 'PublicController', 'places' => $places ]); } #[Route('/latest_changes', name: 'app_public_latest_changes')] public function latestChanges(): Response { // Récupérer les commerces modifiés, triés par date de modification décroissante $places_modified = $this->entityManager->getRepository(Place::class) ->createQueryBuilder('p') ->where('p.modified_date IS NOT NULL') ->orderBy('p.modified_date', 'DESC') ->setMaxResults(20) ->getQuery() ->getResult(); // Récupérer les commerces modifiés, triés par date de modification décroissante $places_displayed = $this->entityManager->getRepository(Place::class) ->createQueryBuilder('p') ->where('p.displayed_date IS NOT NULL') ->orderBy('p.displayed_date', 'DESC') ->setMaxResults(20) ->getQuery() ->getResult(); return $this->render('public/latest_changes.html.twig', [ 'places_modified' => $places_modified, 'places_displayed' => $places_displayed ]); } #[Route('/set_opted_out_place/{uuid}', name: 'app_public_set_opted_out_place')] public function set_opted_out_place($uuid) { $place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]); $this->actionLogger->log('set_place_opted_out', [ 'uuid' => $uuid, ]); if (!$place) { $this->addFlash('warning', 'Ce commerce n\'existe pas.'); return $this->redirectToRoute('app_public_index'); } $place->setOptedOut(true); } #[Route('/ask-for-help', name: 'app_public_ask_for_help')] public function askForHelp(Request $request): Response { $this->actionLogger->log('ask_for_help', []); return $this->redirect('https://www.openstreetmap.fr/contact'); } #[Route('/logs/actions', name: 'app_public_action_logs')] public function listActionLogs(): Response { $logs = $this->actionLogger->getLastLogs(100); return $this->render('public/action_logs.html.twig', [ 'logs' => $logs ]); } #[Route('/add-city', name: 'app_public_add_city')] public function addCity(): Response { return $this->render('public/add_city.html.twig', [ 'controller_name' => 'PublicController', ]); } /** * Ajoute une ville sans déclencher le labourage et redirige vers la page thématique des lieux */ #[Route('/add-city-without-labourage/{insee_code}', name: 'app_public_add_city_without_labourage')] public function addCityWithoutLabourage(string $insee_code): Response { $this->actionLogger->log('add_city_without_labourage', ['insee_code' => $insee_code]); // Vérifier si le code INSEE est valide (composé uniquement de chiffres) if (!ctype_digit($insee_code) || $insee_code == 'undefined' || $insee_code == '') { $this->addFlash('error', 'Code INSEE invalide : il doit être composé uniquement de chiffres.'); $this->actionLogger->log('ERROR_add_city_without_labourage_bad_insee', ['insee_code' => $insee_code]); return $this->redirectToRoute('app_public_index'); } // Récupérer ou créer l'objet Stats $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); if (!$stats) { $stats = new Stats(); $stats->setZone($insee_code); $stats->setKind('request'); // Set the kind to 'request' as it's created from a user request } // Compléter le nom si manquant if (!$stats->getName()) { $cityName = $this->motocultrice->get_city_osm_from_zip_code($insee_code); if ($cityName) { $stats->setName($cityName); } } // Compléter la population si manquante if (!$stats->getPopulation()) { try { $apiUrl = 'https://geo.api.gouv.fr/communes/' . $insee_code; $response = @file_get_contents($apiUrl); if ($response !== false) { $data = json_decode($response, true); if (isset($data['population'])) { $stats->setPopulation((int)$data['population']); } } } catch (\Exception $e) { // Ignorer les erreurs } } // Compléter les lieux d'intérêt si manquants (lat/lon) if (!$stats->getLat() || !$stats->getLon()) { // On tente de récupérer le centre de la ville via l'API geo.gouv.fr try { $apiUrl = 'https://geo.api.gouv.fr/communes/' . $insee_code . '?fields=centre'; $response = @file_get_contents($apiUrl); if ($response !== false) { $data = json_decode($response, true); if (isset($data['centre']['coordinates']) && count($data['centre']['coordinates']) === 2) { $stats->setLon((string)$data['centre']['coordinates'][0]); $stats->setLat((string)$data['centre']['coordinates'][1]); } } } catch (\Exception $e) { // Ignorer les erreurs } } // Sauvegarder l'objet Stats $stats->computeCompletionPercent(); $this->entityManager->persist($stats); $this->entityManager->flush(); // Rediriger vers la page thématique des lieux return $this->redirectToRoute('admin_followup_theme_graph', [ 'insee_code' => $insee_code, 'theme' => 'places' ]); } #[Route('/stats/{insee_code}/followup-graph/{theme}', name: 'app_public_followup_graph', requirements: ['insee_code' => '\\d+', 'theme' => '[a-zA-Z0-9_]+'])] public function publicFollowupGraph(string $insee_code, string $theme): Response { $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); if (!$stats) { $this->addFlash('error', '13 Aucune stats trouvée pour ce code INSEE.'); return $this->redirectToRoute('app_public_index'); } $themes = \App\Service\FollowUpService::getFollowUpThemes(); if (!isset($themes[$theme])) { $this->addFlash('error', 'Thème non reconnu.'); return $this->redirectToRoute('app_public_index'); } // Vérifier si des mesures ont été enregistrées aujourd'hui $today = new \DateTime(); $today->setTime(0, 0, 0); // Début de la journée $hasRecentMeasurements = false; foreach ($stats->getCityFollowUps() as $fu) { if ($fu->getName() === $theme . '_count' || $fu->getName() === $theme . '_completion') { $measureDate = clone $fu->getDate(); $measureDate->setTime(0, 0, 0); if ($measureDate >= $today) { $hasRecentMeasurements = true; break; } } } // Si aucune mesure récente n'existe, générer de nouvelles mesures if (!$hasRecentMeasurements) { $this->actionLogger->log('generate_measurements_on_graph_view', [ 'insee_code' => $insee_code, 'theme' => $theme ]); // Générer les mesures pour tous les thèmes $this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager, true); $this->entityManager->flush(); // Re-fetch the Stats entity to ensure it's managed by the EntityManager $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); } // Récupérer toutes les données de followup pour ce thème $followups = $stats->getCityFollowUps(); $countData = []; $completionData = []; foreach ($followups as $fu) { if ($fu->getName() === $theme . '_count') { $countData[] = [ 'date' => $fu->getDate()->format('Y-m-d'), 'value' => $fu->getMeasure() ]; } if ($fu->getName() === $theme . '_completion') { $completionData[] = [ 'date' => $fu->getDate()->format('Y-m-d'), 'value' => $fu->getMeasure() ]; } } // Trier par date usort($countData, fn($a, $b) => $a['date'] <=> $b['date']); usort($completionData, fn($a, $b) => $a['date'] <=> $b['date']); return $this->render('public/followup_graph.html.twig', [ 'stats' => $stats, 'theme' => $theme, 'theme_label' => $themes[$theme], 'count_data' => json_encode($countData), 'completion_data' => json_encode($completionData), 'icons' => \App\Service\FollowUpService::getFollowUpIcons(), 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, ]); } #[Route('/edit-by-osm/{osm_kind}/{osm_id}', name: 'app_public_edit_by_osm')] public function editByOsm(string $osm_kind, string $osm_id, Request $request): Response { $place = $this->entityManager->getRepository(\App\Entity\Place::class)->findOneBy([ 'osm_kind' => $osm_kind, 'osmId' => $osm_id ]); if ($place) { return $this->redirectToRoute('app_public_edit', [ 'zipcode' => $place->getZipCode(), 'name' => $place->getName() !== '' ? $place->getName() : '?', 'uuid' => $place->getUuidForUrl() ]); } else { $this->addFlash('warning', "Aucun lieu trouvé pour {$osm_kind} {$osm_id}."); $referer = $request->headers->get('referer'); if ($referer) { return $this->redirect($referer); } else { return $this->redirectToRoute('app_public_index'); } } } #[Route('/faq', name: 'faq')] public function faq(): Response { return $this->render('public/faq.html.twig'); } #[Route('/ville/{cityId}/rue/{streetName}', name: 'app_public_street')] public function streetView(string $cityId, string $streetName): Response { $cityStats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $cityId]); if (!$cityStats) { throw $this->createNotFoundException('Ville non trouvée'); } // Décodage du nom de la rue depuis l'URL $streetName = urldecode($streetName); // On récupère tous les lieux dont le stats.zone correspond à cityId et la rue correspondante $places = $this->entityManager->getRepository(Place::class)->createQueryBuilder('p') ->leftJoin('p.stats', 's') ->where('s.zone = :cityId') ->andWhere('p.street = :streetName') ->setParameter('cityId', $cityId) ->setParameter('streetName', $streetName) ->getQuery() ->getResult(); // Conversion des entités Place en tableau associatif pour le JS $placesArray = array_map(fn($place) => $place->toArray(), $places); // Préparer la répartition de complétion pour le graphe $completionBuckets = [ '0-20%' => 0, '20-40%' => 0, '40-60%' => 0, '60-80%' => 0, '80-100%' => 0 ]; foreach ($places as $place) { $c = $place->getCompletionPercentage(); if ($c < 20) $completionBuckets['0-20%']++; elseif ($c < 40) $completionBuckets['20-40%']++; elseif ($c < 60) $completionBuckets['40-60%']++; elseif ($c < 80) $completionBuckets['60-80%']++; else $completionBuckets['80-100%']++; } return $this->render('public/street.html.twig', [ 'city' => $cityStats->getName(), 'city_id' => $cityId, 'street' => $streetName, 'places' => $places, // objets Place pour le HTML 'places_js' => $placesArray, // pour le JS si besoin 'completion_buckets' => $completionBuckets, 'completion_buckets_values' => array_values($completionBuckets), 'stats_url' => $this->generateUrl('app_admin_stats', ['insee_code' => $cityId]), 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null ]); } #[Route('/stats/{insee_code}/evolutions', name: 'app_public_stats_evolutions')] public function statsEvolutions(string $insee_code): Response { $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); if (!$stats) { throw $this->createNotFoundException('Ville non trouvée'); } $now = new \DateTime(); $periods = [ '7j' => (clone $now)->modify('-7 days'), '30j' => (clone $now)->modify('-30 days'), '6mois' => (clone $now)->modify('-6 months'), ]; $followups = $stats->getCityFollowUps(); $types = []; foreach ($followups as $fu) { $name = $fu->getName(); if (str_ends_with($name, '_count')) { $type = substr($name, 0, -6); $types[$type][] = $fu; } } // Récupérer tous les thèmes connus pour afficher même ceux sans données $allThemes = \App\Service\FollowUpService::getFollowUpThemes(); $allIcons = \App\Service\FollowUpService::getFollowUpIcons(); $evolutions = []; // D'abord, traiter les types qui ont des données foreach ($types as $type => $fus) { usort($fus, fn($a, $b) => $a->getDate() <=> $b->getDate()); $latest = end($fus); $evolutions[$type] = [ 'now' => $latest ? $latest->getMeasure() : null ]; foreach ($periods as $label => $date) { $past = null; foreach ($fus as $fu) { if ($fu->getDate() >= $date) { $past = $fu->getMeasure(); break; } } $evolutions[$type][$label] = $past !== null && $latest ? $latest->getMeasure() - $past : null; } } // Ensuite, ajouter les thèmes qui n'ont pas encore de données foreach ($allThemes as $theme => $themeLabel) { if (!isset($evolutions[$theme])) { $evolutions[$theme] = [ 'now' => null ]; foreach ($periods as $periodLabel => $date) { $evolutions[$theme][$periodLabel] = null; } } } // Trier les évolutions par ordre alphabétique des labels uksort($evolutions, function($a, $b) use ($allThemes) { $labelA = $allThemes[$a] ?? $a; $labelB = $allThemes[$b] ?? $b; return strcasecmp($labelA, $labelB); }); // Grouper les lieux par date de modification $places = $stats->getPlaces(); $now = new \DateTime(); $places_7j = []; $places_30j = []; $places_6mois = []; foreach ($places as $place) { $mod = $place->getModifiedDate(); if (!$mod) continue; $diff = $now->diff($mod); $days = (int)$diff->format('%a'); if ($days <= 7) { $places_7j[] = $place; } elseif ($days <= 30) { $places_30j[] = $place; } elseif ($days <= 180) { $places_6mois[] = $place; } } // Tri décroissant par date usort($places_7j, fn($a, $b) => $b->getModifiedDate() <=> $a->getModifiedDate()); usort($places_30j, fn($a, $b) => $b->getModifiedDate() <=> $a->getModifiedDate()); usort($places_6mois, fn($a, $b) => $b->getModifiedDate() <=> $a->getModifiedDate()); return $this->render('public/stats_evolutions.html.twig', [ 'stats' => $stats, 'evolutions' => $evolutions, 'periods' => array_keys($periods), 'places_7j' => $places_7j, 'places_30j' => $places_30j, 'places_6mois' => $places_6mois, 'theme_labels' => $allThemes, 'theme_icons' => $allIcons, ]); } /** * Calculate marker color based on completion percentage * Returns a gradient from intense green (high completion) to gray (low completion) with 10 intermediate shades */ private function calculateMarkerColor(float $completionPercent): string { // Define the colors for the gradient $greenColor = [0, 170, 0]; // Intense green RGB $grayColor = [128, 128, 128]; // Gray RGB // Ensure completion percent is between 0 and 100 $completionPercent = max(0, min(100, $completionPercent)); // Calculate the position in the gradient (0 to 1) $position = $completionPercent / 100; // Calculate the RGB values for the gradient $r = intval($grayColor[0] + ($greenColor[0] - $grayColor[0]) * $position); $g = intval($grayColor[1] + ($greenColor[1] - $grayColor[1]) * $position); $b = intval($grayColor[2] + ($greenColor[2] - $grayColor[2]) * $position); // Convert RGB to hexadecimal return sprintf('#%02x%02x%02x', $r, $g, $b); } #[Route('/cities', name: 'app_public_cities')] public function cities(): Response { // Only select Stats that have an empty kind or 'user' kind $stats = $this->entityManager->getRepository(Stats::class) ->createQueryBuilder('s') // ->where('s.kind IS NULL OR s.kind = :user_kind') // ->setParameter('user_kind', 'user') ->orderBy('s.name', 'ASC') ->getQuery() ->getResult(); // Prepare data for the map $citiesForMap = []; foreach ($stats as $stat) { if ($stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone())) { // Calculate marker color based on completion percentage // Gradient from intense green (high completion) to gray (low completion) with 10 intermediate shades $completionPercent = $stat->getCompletionPercent(); // Ensure we have a float value even if getCompletionPercent returns null $markerColor = $this->calculateMarkerColor($completionPercent ?? 0); $citiesForMap[] = [ 'name' => $stat->getName(), 'zone' => $stat->getZone(), 'lat' => $stat->getLat(), 'lon' => $stat->getLon(), 'placesCount' => $stat->getPlacesCount(), 'completionPercent' => $completionPercent, 'markerColor' => $markerColor, ]; } } return $this->render('public/cities.html.twig', [ 'stats' => $stats, 'citiesForMap' => $citiesForMap, 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, ]); } #[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('/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, ]); } }