osm-labo/src/Controller/PublicController.php
2025-11-25 21:39:10 +01:00

1387 lines
56 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\CityFollowUp;
use App\Entity\Demande;
use App\Entity\Place;
use App\Entity\Stats;
use App\Service\ActionLogger;
use App\Service\FollowUpService;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use GuzzleHttp\Client;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
class PublicController extends AbstractController
{
private $hide_filled_inputs = true;
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private MailerInterface $mailer,
private ActionLogger $actionLogger,
private FollowUpService $followUpService
)
{
}
#[Route('/propose-email/{email}/{type}/{id}', name: 'app_public_propose_email')]
public function proposeEmail(string $email, string $type, int $id): Response
{
$data = $this->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: <a href="' . $this->generateUrl('app_public_edit', [
'zipcode' => $zipCode,
'name' => $place_name,
'uuid' => $existingPlace->getUuidForUrl()
], true) . '">cliquez ici pour accéder au formulaire de modification</a>"';
}
$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('<?xml version="1.0" encoding="UTF-8"?><osm version="0.6"></osm>');
$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('<?xml version="1.0" encoding="UTF-8"?><osm version="0.6"></osm>');
$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,
]);
}
}