add loggers actions

This commit is contained in:
Tykayn 2025-06-26 23:14:22 +02:00 committed by tykayn
parent 59398d14ba
commit 12d4db370f
22 changed files with 517 additions and 218 deletions

View file

@ -36,9 +36,8 @@ import {
toggleCompletionInfo, toggleCompletionInfo,
updateMapHeightForLargeScreens updateMapHeightForLargeScreens
} from './utils.js'; } from './utils.js';
// import Tablesort from 'tablesort'; import tableSortJs from 'table-sort-js/table-sort.js';
import TableSort from 'table-sort-js/table-sort.js'; console.log('TableSort', tableSortJs)
console.log('TableSort', TableSort)
// Charger table-sortable (version non minifiée locale) // Charger table-sortable (version non minifiée locale)
// import '../assets/js/table-sortable.js'; // import '../assets/js/table-sortable.js';
@ -203,11 +202,10 @@ document.addEventListener('DOMContentLoaded', () => {
adjustListGroupFontSize('.list-group-item'); adjustListGroupFontSize('.list-group-item');
// Activer le tri naturel sur tous les tableaux avec la classe table-sort // Activer le tri naturel sur tous les tableaux avec la classe table-sort
if(TableSort){ if (tableSortJs) {
tableSortJs();
document.querySelectorAll('table.table-sort')?.forEach(table => { }else{
new TableSort(table); console.log('pas de tablesort')
});
} }
// Initialisation du tri et filtrage sur les tableaux du dashboard et de la page stats // Initialisation du tri et filtrage sur les tableaux du dashboard et de la page stats

View file

@ -31,6 +31,11 @@ body {
color: #df5a0d; color: #df5a0d;
} }
table {
max-height: 100vh;
max-width: 100vw;
}
table.js-sort-table th { table.js-sort-table th {
cursor: pointer; cursor: pointer;
} }
@ -141,7 +146,7 @@ img {
} }
#completionHistoryChart{ #completionHistoryChart {
min-height: 500px; min-height: 500px;
} }

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250626204942 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE action_log (id INT AUTO_INCREMENT NOT NULL, kind VARCHAR(255) NOT NULL, from_url VARCHAR(500) NOT NULL, who VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE action_log
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250626205820 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log ADD from_url VARCHAR(500) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log DROP from_url
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250626210012 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log ADD who VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE action_log DROP who
SQL);
}
}

9
package-lock.json generated
View file

@ -6,7 +6,8 @@
"": { "": {
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"jquery": "^3.7.1" "jquery": "^3.7.1",
"table-sort": "^1.0.16"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.0", "@babel/core": "^7.17.0",
@ -5501,6 +5502,12 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/table-sort": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/table-sort/-/table-sort-1.0.16.tgz",
"integrity": "sha512-w7TDMfszdFY36aWQKRiAg0qQjOmvIy1IQKplmgpOCimOZ69BP4y5Ne4+jBQeYn990Rn40/wCALR0eAcLqxECWA==",
"license": "ISC"
},
"node_modules/table-sort-js": { "node_modules/table-sort-js": {
"version": "1.22.2", "version": "1.22.2",
"resolved": "https://registry.npmjs.org/table-sort-js/-/table-sort-js-1.22.2.tgz", "resolved": "https://registry.npmjs.org/table-sort-js/-/table-sort-js-1.22.2.tgz",

View file

@ -22,6 +22,7 @@
"build": "encore production --progress" "build": "encore production --progress"
}, },
"dependencies": { "dependencies": {
"jquery": "^3.7.1" "jquery": "^3.7.1",
"table-sort": "^1.0.16"
} }
} }

View file

@ -17,6 +17,7 @@ use function uuid_create;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Twig\Environment; use Twig\Environment;
use App\Service\ActionLogger;
final class AdminController extends AbstractController final class AdminController extends AbstractController
{ {
@ -25,7 +26,8 @@ final class AdminController extends AbstractController
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice, private Motocultrice $motocultrice,
private BudgetService $budgetService, private BudgetService $budgetService,
private Environment $twig private Environment $twig,
private ActionLogger $actionLogger
) { ) {
} }
@ -145,6 +147,7 @@ final class AdminController extends AbstractController
$stats->setAvecSite($calculatedStats['counters']['avec_site']); $stats->setAvecSite($calculatedStats['counters']['avec_site']);
$stats->setAvecAccessibilite($calculatedStats['counters']['avec_accessibilite']); $stats->setAvecAccessibilite($calculatedStats['counters']['avec_accessibilite']);
$stats->setAvecNote($calculatedStats['counters']['avec_note']); $stats->setAvecNote($calculatedStats['counters']['avec_note']);
$stats->setCompletionPercent($calculatedStats['completion_percent']); $stats->setCompletionPercent($calculatedStats['completion_percent']);
// Associer les stats à chaque commerce // Associer les stats à chaque commerce
@ -730,15 +733,9 @@ final class AdminController extends AbstractController
// Afficher le log des objets non trouvés à la fin // Afficher le log des objets non trouvés à la fin
if (!empty($notFoundOsmKeys)) { if (!empty($notFoundOsmKeys)) {
return $this->render('admin/labourage_results.html.twig', [ $this->addFlash('info', count($notFoundOsmKeys).' objets OSM non trouvés lors du labourage.');
'stats' => $stats,
'zone' => $insee_code,
'new_places_counter' => $processedCount,
'commerces' => $commerces,
'not_found_osm_keys' => $notFoundOsmKeys
]);
} }
// Sinon, rediriger comme avant // Rediriger dans tous les cas vers la page de stats de la ville
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]); return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors du labourage : ' . $e->getMessage()); $this->addFlash('error', 'Erreur lors du labourage : ' . $e->getMessage());

View file

@ -13,6 +13,7 @@ use GuzzleHttp\Client;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use App\Service\ActionLogger;
class PublicController extends AbstractController class PublicController extends AbstractController
{ {
@ -22,19 +23,19 @@ class PublicController extends AbstractController
public function __construct( public function __construct(
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice, private Motocultrice $motocultrice,
private MailerInterface $mailer private MailerInterface $mailer,
) { private ActionLogger $actionLogger
} ) {}
#[Route('/propose-email/{email}/{type}/{id}', name: 'app_public_propose_email')] #[Route('/propose-email/{email}/{type}/{id}', name: 'app_public_propose_email')]
public function proposeEmail(string $email, string $type, int $id): Response public function proposeEmail(string $email, string $type, int $id): Response
{ {
$data = $this->motocultrice->get_osm_object_data($type, $id); $data = $this->motocultrice->get_osm_object_data($type, $id);
// Récupérer le code postal depuis les tags, sinon mettre -1 // 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; $zipCode = isset($data['tags_converted']['addr:postcode']) ? (int)$data['tags_converted']['addr:postcode'] : -1;
$place_name = $data['tags_converted']['name']; $place_name = $data['tags_converted']['name'];
// Vérifier si une Place existe déjà avec le même osm_kind et osmId // Vérifier si une Place existe déjà avec le même osm_kind et osmId
$existingPlace = $this->entityManager->getRepository(Place::class)->findOneBy([ $existingPlace = $this->entityManager->getRepository(Place::class)->findOneBy([
@ -45,10 +46,10 @@ class PublicController extends AbstractController
if ($existingPlace) { if ($existingPlace) {
// Mettre à jour l'email de la Place existante // Mettre à jour l'email de la Place existante
$existingPlace->setEmail($email)->setLastContactAttemptDate(new \DateTime()); $existingPlace->setEmail($email)->setLastContactAttemptDate(new \DateTime());
if($zipCode != -1) { if ($zipCode != -1) {
$existingPlace->setZipCode($zipCode); $existingPlace->setZipCode($zipCode);
} }
$this->entityManager->persist($existingPlace); $this->entityManager->persist($existingPlace);
$this->entityManager->flush(); $this->entityManager->flush();
$debug = ''; $debug = '';
@ -59,28 +60,28 @@ class PublicController extends AbstractController
'uuid' => $existingPlace->getUuidForUrl() 'uuid' => $existingPlace->getUuidForUrl()
], true); ], true);
} }
$this->addFlash('success', 'L\'email a été mis à jour. Un email vous sera envoyé avec le lien de modification. '.$debug); $this->addFlash('success', 'L\'email a été mis à jour. Un email vous sera envoyé avec le lien de modification. ' . $debug);
} else { } else {
// Créer une nouvelle entité Place // Créer une nouvelle entité Place
$place = new Place(); $place = new Place();
$place->setEmail($email) $place->setEmail($email)
->setOsmId($id) ->setOsmId($id)
->setOsmKind($type) ->setOsmKind($type)
->setAskedHumainsSupport(false) ->setAskedHumainsSupport(false)
->setOptedOut(false) ->setOptedOut(false)
->setDead(false) ->setDead(false)
->setNote('') ->setNote('')
->setModifiedDate(new \DateTime()) ->setModifiedDate(new \DateTime())
->setZipCode($zipCode) ->setZipCode($zipCode)
->setPlaceCount(0) ->setPlaceCount(0)
->setMainTag($this->motocultrice->find_main_tag($data['tags_converted']) ?? '') ->setMainTag($this->motocultrice->find_main_tag($data['tags_converted']) ?? '')
->setStreet($this->motocultrice->find_street($data['tags_converted']) ?? '') ->setStreet($this->motocultrice->find_street($data['tags_converted']) ?? '')
->setHousenumber($this->motocultrice->find_housenumber($data['tags_converted']) ?? '') ->setHousenumber($this->motocultrice->find_housenumber($data['tags_converted']) ?? '')
->setLastContactAttemptDate(new \DateTime()) ->setLastContactAttemptDate(new \DateTime())
->setUuidForUrl(uniqid()); ->setUuidForUrl(uniqid());
$this->entityManager->persist($place); $this->entityManager->persist($place);
$this->entityManager->flush(); $this->entityManager->flush();
@ -93,7 +94,7 @@ class PublicController extends AbstractController
'uuid' => $place->getUuidForUrl() 'uuid' => $place->getUuidForUrl()
], true); ], true);
} }
$this->addFlash('success', 'Un email vous sera envoyé avec le lien de modification. '.$debug); $this->addFlash('success', 'Un email vous sera envoyé avec le lien de modification. ' . $debug);
} }
// Envoyer l'email // Envoyer l'email
@ -118,7 +119,7 @@ class PublicController extends AbstractController
public function index(): Response public function index(): Response
{ {
$stats = $this->entityManager->getRepository(Stats::class)->findAll(); $stats = $this->entityManager->getRepository(Stats::class)->findAll();
return $this->render('public/home.html.twig', [ return $this->render('public/home.html.twig', [
'controller_name' => 'PublicController', 'controller_name' => 'PublicController',
'stats' => $stats 'stats' => $stats
@ -128,9 +129,15 @@ class PublicController extends AbstractController
#[Route('/edit/{zipcode}/{name}/{uuid}', name: 'app_public_edit')] #[Route('/edit/{zipcode}/{name}/{uuid}', name: 'app_public_edit')]
public function edit_with_uuid($zipcode, $name, $uuid): Response public function edit_with_uuid($zipcode, $name, $uuid): Response
{ {
$this->actionLogger->log('dashboard', [
'zipcode' => $zipcode,
]);
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]);
if (!$place) { if (!$place) {
$this->addFlash('warning', 'Ce lien de modification n\'existe pas.'.$uuid); $this->addFlash('warning', 'Ce lien de modification n\'existe pas.' . $uuid);
return $this->redirectToRoute('app_public_index'); return $this->redirectToRoute('app_public_index');
} }
@ -142,19 +149,19 @@ class PublicController extends AbstractController
// récupérer les tags de base // récupérer les tags de base
$base_tags = $this->motocultrice->base_tags; $base_tags = $this->motocultrice->base_tags;
$base_tags = array_fill_keys($base_tags, ''); $base_tags = array_fill_keys($base_tags, '');
$commerce_overpass = $this->motocultrice->get_osm_object_data($place->getOsmKind(), $place->getOsmId()); $commerce_overpass = $this->motocultrice->get_osm_object_data($place->getOsmKind(), $place->getOsmId());
// Fusionner les tags de base avec les tags existants // Fusionner les tags de base avec les tags existants
$commerce_overpass['tags_converted'] = array_merge($base_tags, $commerce_overpass['tags_converted']); $commerce_overpass['tags_converted'] = array_merge($base_tags, $commerce_overpass['tags_converted']);
// Trier les tags par ordre alphabétique des clés // Trier les tags par ordre alphabétique des clés
ksort($commerce_overpass['tags_converted']); ksort($commerce_overpass['tags_converted']);
$place->setDisplayedDate(new \DateTime()); $place->setDisplayedDate(new \DateTime());
$this->entityManager->persist($place); $this->entityManager->persist($place);
$this->entityManager->flush(); $this->entityManager->flush();
return $this->render('public/edit.html.twig', [ return $this->render('public/edit.html.twig', [
'commerce_overpass' => $commerce_overpass, 'commerce_overpass' => $commerce_overpass,
'name' => $name, 'name' => $name,
@ -172,7 +179,9 @@ class PublicController extends AbstractController
#[Route('/dashboard', name: 'app_public_dashboard')] #[Route('/dashboard', name: 'app_public_dashboard')]
public function dashboard(): Response public function dashboard(): Response
{ {
$this->actionLogger->log('dashboard', []);
$stats_repo = $this->entityManager->getRepository(Stats::class)->findAll(); $stats_repo = $this->entityManager->getRepository(Stats::class)->findAll();
$stats_for_chart = []; $stats_for_chart = [];
@ -204,6 +213,12 @@ class PublicController extends AbstractController
#[Route('/modify/{osm_object_id}/{version}/{changesetID}', name: 'app_public_submit')] #[Route('/modify/{osm_object_id}/{version}/{changesetID}', name: 'app_public_submit')]
public function submit($osm_object_id, $version, $changesetID): Response public function submit($osm_object_id, $version, $changesetID): 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]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['osmId' => $osm_object_id]);
if (!$place) { if (!$place) {
$this->addFlash('warning', 'Ce commerce n\'existe pas.'); $this->addFlash('warning', 'Ce commerce n\'existe pas.');
@ -212,19 +227,19 @@ class PublicController extends AbstractController
// Récupérer les données POST // Récupérer les données POST
$request = Request::createFromGlobals(); $request = Request::createFromGlobals();
// Vérifier si des données ont été soumises // Vérifier si des données ont été soumises
if ($request->isMethod('POST')) { if ($request->isMethod('POST')) {
$status = "non modifié"; $status = "non modifié";
// Récupérer le type d'objet (node ou way) // Récupérer le type d'objet (node ou way)
$osm_kind = $request->request->get('osm_kind', 'node'); $osm_kind = $request->request->get('osm_kind', 'node');
// Récupérer tous les tags du formulaire // Récupérer tous les tags du formulaire
$tags = []; $tags = [];
$request_post = $request->request->all(); $request_post = $request->request->all();
$request_post = $this->motocultrice->map_post_values($request_post); $request_post = $this->motocultrice->map_post_values($request_post);
foreach ($request_post as $key => $value) { foreach ($request_post as $key => $value) {
@ -258,7 +273,7 @@ class PublicController extends AbstractController
$tag = $changeset->addChild('tag'); $tag = $changeset->addChild('tag');
$tag->addAttribute('k', 'created_by'); $tag->addAttribute('k', 'created_by');
$tag->addAttribute('v', 'OSM Mon Commerce Web Editor'); $tag->addAttribute('v', 'OSM Mon Commerce Web Editor');
$tag = $changeset->addChild('tag'); $tag = $changeset->addChild('tag');
$tag->addAttribute('k', 'comment'); $tag->addAttribute('k', 'comment');
$tag->addAttribute('v', 'Modification dans #MonCommerceOSM'); $tag->addAttribute('v', 'Modification dans #MonCommerceOSM');
@ -275,14 +290,14 @@ class PublicController extends AbstractController
// Récupérer les données actuelles de l'objet // Récupérer les données actuelles de l'objet
$currentObjectData = $this->motocultrice->get_osm_object_data($osm_kind, $osm_object_id); $currentObjectData = $this->motocultrice->get_osm_object_data($osm_kind, $osm_object_id);
// 2. Modifier l'objet avec le nouveau changeset // 2. Modifier l'objet avec le nouveau changeset
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><osm version="0.6"></osm>'); $xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><osm version="0.6"></osm>');
$object = $xml->addChild($osm_kind); $object = $xml->addChild($osm_kind);
$object->addAttribute('id', $osm_object_id); $object->addAttribute('id', $osm_object_id);
$object->addAttribute('version', $version); $object->addAttribute('version', $version);
$object->addAttribute('changeset', $newChangesetId); $object->addAttribute('changeset', $newChangesetId);
// Ajouter les coordonnées pour les nodes // Ajouter les coordonnées pour les nodes
if ($osm_kind === 'node') { if ($osm_kind === 'node') {
if (!isset($currentObjectData['@attributes']['lat']) || !isset($currentObjectData['@attributes']['lon'])) { if (!isset($currentObjectData['@attributes']['lat']) || !isset($currentObjectData['@attributes']['lon'])) {
@ -291,7 +306,7 @@ class PublicController extends AbstractController
$object->addAttribute('lat', $currentObjectData['@attributes']['lat']); $object->addAttribute('lat', $currentObjectData['@attributes']['lat']);
$object->addAttribute('lon', $currentObjectData['@attributes']['lon']); $object->addAttribute('lon', $currentObjectData['@attributes']['lon']);
} }
// Ajouter les tags // Ajouter les tags
foreach ($tags as $key => $value) { foreach ($tags as $key => $value) {
if (!empty($key) && !empty($value)) { if (!empty($key) && !empty($value)) {
@ -323,18 +338,26 @@ class PublicController extends AbstractController
$status = "Les tags ont été mis à jour avec succès"; $status = "Les tags ont été mis à jour avec succès";
} else { } else {
$status = "Erreur lors de la mise à jour des tags"; $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) { } catch (\Exception $e) {
$status = "Erreur lors de la communication avec l'API OSM: " . $e->getMessage(); $status = "Erreur lors de la communication avec l'API OSM: " . $e->getMessage();
$exception = true; $exception = true;
$exception_message = $e->getMessage(); $exception_message = $e->getMessage();
// Debug de la réponse en cas d'erreur // Debug de la réponse en cas d'erreur
if (method_exists($e, 'getResponse')) { if (method_exists($e, 'getResponse')) {
var_dump($e->getResponse()->getBody()->getContents()); var_dump($e->getResponse()->getBody()->getContents());
} }
} }
} }
// après envoi on récupère les données // après envoi on récupère les données
$commerce_overpass = $this->motocultrice->get_osm_object_data($osm_kind, $osm_object_id); $commerce_overpass = $this->motocultrice->get_osm_object_data($osm_kind, $osm_object_id);
@ -344,14 +367,14 @@ class PublicController extends AbstractController
$this->entityManager->clear(); $this->entityManager->clear();
$stats = $place->getStats(); $stats = $place->getStats();
if(!$stats) { if (!$stats) {
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zip_code' => $place->getZipCode()]); $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zip_code' => $place->getZipCode()]);
} }
if(!$stats) { if (!$stats) {
$stats = new Stats(); $stats = new Stats();
$stats->setZipCode($place->getZipCode()); $stats->setZipCode($place->getZipCode());
} }
$stats->addPlace($place); $stats->addPlace($place);
$place->setStats($stats); $place->setStats($stats);
$place->setModifiedDate(new \DateTime()); $place->setModifiedDate(new \DateTime());
@ -379,23 +402,31 @@ class PublicController extends AbstractController
#[Route('/request_email_to_modify/{osm_object_id}', name: 'app_public_request_email')] #[Route('/request_email_to_modify/{osm_object_id}', name: 'app_public_request_email')]
public function request_email($osm_object_id): Response public function request_email($osm_object_id): Response
{ {
$this->actionLogger->log('request_email_to_modify', [
'osm_id' => $osm_object_id,
]);
if ($this->getRequest()->isMethod('POST')) { if ($this->getRequest()->isMethod('POST')) {
$email = $this->getRequest()->request->get('email'); $email = $this->getRequest()->request->get('email');
try { try {
// TODO: Implémenter l'envoi réel du mail // TODO: Implémenter l'envoi réel du mail
$this->addFlash( $this->addFlash(
'success', 'success',
'Un email vous a été envoyé avec les instructions pour modifier ce lieu.' 'Un email vous a été envoyé avec les instructions pour modifier ce lieu.'
); );
} catch (\Exception $e) { } catch (\Exception $e) {
$this->actionLogger->log('ERROR_request_email_to_modify', [
'osm_id' => $osm_object_id,
'exception_message' => $e->getMessage(),
]);
$this->addFlash( $this->addFlash(
'error', 'error',
'Une erreur est survenue lors de l\'envoi de l\'email. Veuillez réessayer plus tard.' 'Une erreur est survenue lors de l\'envoi de l\'email. Veuillez réessayer plus tard.'
); );
} }
return $this->redirectToRoute('app_public_index'); return $this->redirectToRoute('app_public_index');
} }
// TODO envoyer un email // TODO envoyer un email
@ -409,6 +440,9 @@ class PublicController extends AbstractController
#[Route('/closed_commerce/{osm_object_id}', name: 'app_public_closed_commerce')] #[Route('/closed_commerce/{osm_object_id}', name: 'app_public_closed_commerce')]
public function closed_commerce($osm_object_id): Response 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]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_id' => $osm_object_id]);
if (!$place) { if (!$place) {
$this->addFlash('warning', 'Ce commerce n\'existe pas.'); $this->addFlash('warning', 'Ce commerce n\'existe pas.');
@ -417,7 +451,7 @@ class PublicController extends AbstractController
$place->setClosed(true); $place->setClosed(true);
$this->entityManager->flush(); $this->entityManager->flush();
return $this->render('public/closed_commerce.html.twig', [ return $this->render('public/closed_commerce.html.twig', [
'controller_name' => 'PublicController', 'controller_name' => 'PublicController',
]); ]);
@ -474,12 +508,33 @@ class PublicController extends AbstractController
} }
#[Route('/set_opted_out_place/{uuid}', name: 'app_public_set_opted_out_place')] #[Route('/set_opted_out_place/{uuid}', name: 'app_public_set_opted_out_place')]
public function set_opted_out_place($uuid): Response public function set_opted_out_place($uuid)
{ {
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]); $place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]);
$this->actionLogger->log('set_place_opted_out', [
'uuid' => $uuid,
]);
if (!$place) { if (!$place) {
$this->addFlash('warning', 'Ce commerce n\'existe pas.'); $this->addFlash('warning', 'Ce commerce n\'existe pas.');
return $this->redirectToRoute('app_public_index'); return $this->redirectToRoute('app_public_index');
} }
} }
}
#[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
]);
}
}

91
src/Entity/ActionLog.php Normal file
View file

@ -0,0 +1,91 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class ActionLog
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 100)]
private ?string $type = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $data = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $from_url = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $who = null;
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getData(): ?array
{
return $this->data;
}
public function setData(?array $data): self
{
$this->data = $data;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getFromUrl(): ?string
{
return $this->from_url;
}
public function setFromUrl(?string $from_url): static
{
$this->from_url = $from_url;
return $this;
}
public function getWho(): ?string
{
return $this->who;
}
public function setWho(?string $who): static
{
$this->who = $who;
return $this;
}
}

View file

@ -196,7 +196,9 @@ class Stats
$this->avec_site = 0; $this->avec_site = 0;
$this->avec_accessibilite = 0; $this->avec_accessibilite = 0;
$this->avec_note = 0; $this->avec_note = 0;
$this->avec_siret = 0;
$this->avec_name = 0;
$somme_completions = 0; $somme_completions = 0;
@ -219,10 +221,13 @@ class Stats
$this->avec_horaires++; $this->avec_horaires++;
$place_completions++; $place_completions++;
} }
if($place->hasNote()) { if($place->getSiret()) {
$this->avec_note++; $this->avec_siret++;
} }
$somme_completions += $place_completions / 5; if($place->getName()) {
$this->avec_name++;
}
$somme_completions += $place_completions / 6;
} }
$this->setPlacesCount($places_count); $this->setPlacesCount($places_count);

View file

@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\ActionLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ActionLog>
*/
class ActionLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ActionLog::class);
}
// /**
// * @return ActionLog[] Returns an array of ActionLog objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('a.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ActionLog
// {
// return $this->createQueryBuilder('a')
// ->andWhere('a.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\ActionLog;
class ActionLogger
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
public function log(string $type, array $data = []): void
{
$log = new ActionLog();
$log->setType($type);
$log->setData($data);
$log->setCreatedAt(new \DateTime());
if (isset($data['from_url'])) {
$log->setFromUrl($data['from_url']);
} else if (isset($_SERVER['HTTP_REFERER'])) {
$log->setFromUrl($_SERVER['HTTP_REFERER']);
}
if (isset($data['who'])) {
$log->setWho($data['who']);
}
$this->entityManager->persist($log);
$this->entityManager->flush();
}
/**
* @return ActionLog[]
*/
public function getLastLogs(int $limit = 100): array
{
return $this->entityManager->getRepository(ActionLog::class)
->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}

View file

@ -80,7 +80,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: 'Fraîcheur des données OSM (par mois)' } title: { display: true, text: 'Fraîcheur des données OSM (par mois)' }
@ -126,7 +126,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: 'Fraîcheur des données OSM (par trimestre)' } title: { display: true, text: 'Fraîcheur des données OSM (par trimestre)' }
@ -166,7 +166,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: 'Fraîcheur des données OSM (par année)' } title: { display: true, text: 'Fraîcheur des données OSM (par année)' }
@ -213,7 +213,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: "Distribution des villes par habitants/lieu (par pas de 10)" }, title: { display: true, text: "Distribution des villes par habitants/lieu (par pas de 10)" },
@ -258,7 +258,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: "Distribution des villes par lieux/habitant (par pas de 0,01)" }, title: { display: true, text: "Distribution des villes par lieux/habitant (par pas de 0,01)" },
@ -319,7 +319,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: "Distribution des villes par budget par habitant (par pas de 100€)" }, title: { display: true, text: "Distribution des villes par budget par habitant (par pas de 100€)" },
@ -365,7 +365,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: "Distribution des villes par écart à la moyenne du budget par habitant (par pas de 10%)" }, title: { display: true, text: "Distribution des villes par écart à la moyenne du budget par habitant (par pas de 10%)" },
@ -411,7 +411,7 @@ document.addEventListener('DOMContentLoaded', function() {
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
title: { display: true, text: "Distribution des villes par budget par lieu (par pas de 5000€)" }, title: { display: true, text: "Distribution des villes par budget par lieu (par pas de 5000€)" },

View file

@ -34,5 +34,14 @@ commerces existants déjà en base: {{ commerces|length }}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if redirect_to_stats %}
<script>
setTimeout(function() {
window.location.href = '{{ path('app_admin_stats', {'insee_code': zone}) }}';
}, 5000);
</script>
<div class="alert alert-info mt-4">Vous allez être redirigé automatiquement vers la page de statistiques de la ville dans 5 secondes.</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -43,6 +43,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if row.completion_pondere_normalisee is not null %} {% if row.completion_pondere_normalisee is not null %}
{{ row.completion_pondere_normalisee }} {{ row.completion_pondere_normalisee }}
{% else %} {% else %}

View file

@ -101,72 +101,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{# Affichage de la fraîcheur des données OSM #}
{# {% if stats.osmDataDateMin and stats.osmDataDateMax and stats.osmDataDateAvg %}
{% set now = "now"|date("U") %}
{% set minDate = stats.osmDataDateMin|date("U") %}
{% set maxDate = stats.osmDataDateMax|date("U") %}
{% set avgDate = stats.osmDataDateAvg|date("U") %}
{% set minDiff = now - minDate %}
{% set maxDiff = now - maxDate %}
{% set avgDiff = now - avgDate %}
{% set minYears = (minDiff / 31536000)|round(0, 'floor') %}
{% set minMonths = ((minDiff % 31536000) / 2592000)|round(0, 'floor') %}
{% set maxYears = (maxDiff / 31536000)|round(0, 'floor') %}
{% set maxMonths = ((maxDiff % 31536000) / 2592000)|round(0, 'floor') %}
{% set avgYears = (avgDiff / 31536000)|round(0, 'floor') %}
{% set avgMonths = ((avgDiff % 31536000) / 2592000)|round(0, 'floor') %}
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info osm-freshness-info">
<i class="bi bi-clock-history"></i>
<strong>Fraîcheur des données OSM :</strong>
{% if minYears == maxYears and minMonths == maxMonths %}
Toutes les modifications ont été faites il y a
{% if minYears > 0 %}
{{ minYears }} an{{ minYears > 1 ? 's' : '' }}
{% if minMonths > 0 %}, {{ minMonths }} mois{% endif %}
{% elseif minMonths > 0 %}
{{ minMonths }} mois
{% else %}
moins d'un mois
{% endif %}
{% else %}
Les modifications ont été faites entre il y a
{% if maxYears > 0 %}
{{ maxYears }} an{{ maxYears > 1 ? 's' : '' }}
{% if maxMonths > 0 %}, {{ maxMonths }} mois{% endif %}
{% elseif maxMonths > 0 %}
{{ maxMonths }} mois
{% else %}
moins d'un mois
{% endif %}
et il y a
{% if minYears > 0 %}
{{ minYears }} an{{ minYears > 1 ? 's' : '' }}
{% if minMonths > 0 %}, {{ minMonths }} mois{% endif %}
{% elseif minMonths > 0 %}
{{ minMonths }} mois
{% else %}
moins d'un mois
{% endif %},
en moyenne il y a
{% if avgYears > 0 %}
{{ avgYears }} an{{ avgYears > 1 ? 's' : '' }}
{% if avgMonths > 0 %}, {{ avgMonths }} mois{% endif %}
{% elseif avgMonths > 0 %}
{{ avgMonths }} mois
{% else %}
moins d'un mois
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endif %} #}
<div class="row"> <div class="row">
<div class="col-md-3 col-12"> <div class="col-md-3 col-12">
@ -258,9 +193,8 @@
</div> </div>
</div> </div>
<div class="card mt-4"> <div class="card mt-4">
{% include 'admin/stats_history.html.twig' %} {% include 'admin/stats_history.html.twig' with {stat: stats} %}
<div id="distribution_completion" class="mt-4 mb-4"></div> <canvas id="distribution_completion" class="mt-4 mb-4" height="600"></canvas>
<div id="distribution_budget_habitant" class="mt-4 mb-4"></div>
<div class="row"> <div class="row">
@ -603,8 +537,7 @@
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: true,
plugins: { plugins: {
legend: { legend: {
position: 'right', position: 'right',
@ -644,7 +577,7 @@ if(dc ){
return ; return ;
} }
new Chart(completionCtx, { new Chart(completionCtx, {
type: 'bar', type: 'line',
data: { data: {
labels: completionLabels, labels: completionLabels,
datasets: [{ datasets: [{
@ -661,59 +594,13 @@ if(dc ){
beginAtZero: true beginAtZero: true
} }
}, },
responsive: true, responsive: true,
maintainAspectRatio: false
} }
}); });
}else{ }else{
console.log('pas de distribution_completion') console.log('pas de distribution_completion')
} }
// === Distribution du budget par habitant (écart à la moyenne, pas de 3%) ===
const budgetEcartData = [];
{% for commerce in stats.places %}
{% if commerce.getBudgetParHabitantEcartPourcent is defined %}
budgetEcartData.push({{ commerce.getBudgetParHabitantEcartPourcent()|default(0) }});
{% endif %}
{% endfor %}
const budgetEcartDistribution = {};
budgetEcartData.forEach(ecart => {
const range = Math.floor(ecart / 3) * 3;
const key = `${range}% à ${range + 3}%`;
budgetEcartDistribution[key] = (budgetEcartDistribution[key] || 0) + 1;
});
const budgetEcartLabels = Object.keys(budgetEcartDistribution).sort((a, b) => parseInt(a) - parseInt(b));
const budgetEcartValues = budgetEcartLabels.map(label => budgetEcartDistribution[label]);
const dbh = document.getElementById('distribution_budget_habitant');
if(dbh){
const budgetEcartCtx = dbh.getContext ? dbh.getContext('2d') : null;
if(!budgetEcartCtx){
console.log('pas de budgetEcartCtx');
} else {
new Chart(budgetEcartCtx, {
type: 'bar',
data: {
labels: budgetEcartLabels,
datasets: [{
label: 'Distribution des villes selon l\'écart à la moyenne du budget par habitant (pas 3%)',
data: budgetEcartValues,
backgroundColor: 'rgba(255, 206, 86, 0.5)',
borderColor: 'rgba(255, 206, 86, 1)',
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
},
responsive: true,
maintainAspectRatio: false
}
});
}
}
}); });
</script> </script>
<script> <script>

View file

@ -33,25 +33,42 @@
{{ commerce.getCompletionPercentage() }} {{ commerce.getCompletionPercentage() }}
<div class='p-2'> <div class='p-2'>
<h6>Infos manquantes :</h6> {# <h6>Infos manquantes :</h6> #}
<ul class='list-unstyled mb-0'> <ul class='list-unstyled list-inline mb-0'>
{% if not commerce.name %} {% if not commerce.name %}
<li><i class='bi bi-x-circle text-danger'></i> Nom du commerce</li> <span title="Nom du commerce">
<i class='bi bi-person-badge text-danger'></i>
</span>
{% endif %} {% endif %}
{% if not commerce.hasAddress() %} {% if not commerce.hasAddress() %}
<li><i class='bi bi-x-circle text-danger'></i> Adresse complète</li> <span title="Adresse complète">
<i class='bi bi-geo-alt text-danger'></i>
</li>
{% endif %} {% endif %}
{% if not commerce.hasOpeningHours() %} {% if not commerce.hasOpeningHours() %}
<li><i class='bi bi-x-circle text-danger'></i> Horaires d'ouverture</li> <span title="Horaires d'ouverture">
<i class='bi bi-clock text-danger'></i>
</span>
{% endif %} {% endif %}
{% if not commerce.hasWebsite() %} {% if not commerce.hasWebsite() %}
<li><i class='bi bi-x-circle text-danger'></i> Site web</li> <span title="Site web">
<i class='bi bi-globe text-danger'></i>
</span>
{% endif %} {% endif %}
{# {% if not commerce.phone %} {# {% if not commerce.phone %}
<li><i class='bi bi-x-circle text-danger'></i> Téléphone</li> <li title="Téléphone">
<i class='bi bi-telephone text-danger'></i>
</li>
{% endif %} #} {% endif %} #}
{% if not commerce.hasWheelchair() %} {% if not commerce.hasWheelchair() %}
<li><i class='bi bi-x-circle text-danger'></i> Accessibilité PMR</li> <span title="Accessibilité PMR">
<i class='bi bi-universal-access text-danger'></i>
</span>
{% endif %}
{% if not commerce.siret %}
<span title="SIRET">
<i class='bi bi-book text-danger'></i>
</span>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>

View file

@ -3,7 +3,7 @@
<h2>Évolution du taux de complétion</h2> <h2>Évolution du taux de complétion</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<canvas id="completionHistoryChart"></canvas> <canvas id="completionHistoryChart" height="500"></canvas>
</div> </div>
</div> </div>
@ -32,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function() {
0{% if not loop.last %},{% endif %} 0{% if not loop.last %},{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
]; ];
const addressData = [ const addressData = [
{% for stat in statsHistory|reverse %} {% for stat in statsHistory|reverse %}
@ -73,6 +73,7 @@ document.addEventListener('DOMContentLoaded', function() {
{% endif %} {% endif %}
{% endfor %} {% endfor %}
]; ];
const completionStat = {{stat.getCompletionPercent()}}
new Chart(ctx, { new Chart(ctx, {
type: 'line', type: 'line',
@ -140,7 +141,7 @@ document.addEventListener('DOMContentLoaded', function() {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Évolution des taux de complétion par aspect au fil du temps' text: 'Évolution des taux de complétion dans le temps => '+completionStat
}, },
legend: { legend: {
position: 'top', position: 'top',

View file

@ -117,7 +117,7 @@
<!-- Script pour le tri automatique des tableaux --> <!-- Script pour le tri automatique des tableaux -->
{# <script src="{{ asset('js/bootstrap/Sortable.min.js') }}"></script> #} {# <script src="{{ asset('js/bootstrap/Sortable.min.js') }}"></script> #}
<script src="{{ asset('js/qrcode/qrcode.min.js') }}"></script> <script src="{{ asset('js/qrcode/qrcode.min.js') }}"></script>
<script> <script>
new QRCode(document.getElementById('qrcode'), { new QRCode(document.getElementById('qrcode'), {
text: window.location.href, text: window.location.href,

View file

@ -0,0 +1,29 @@
{% extends 'base.html.twig' %}
{% block title %}Logs d'action{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>100 derniers logs d'action</h1>
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Données</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.createdAt|date('Y-m-d H:i:s') }}</td>
<td>{{ log.type }}</td>
<td><pre style="white-space: pre-wrap;">{{ log.data|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre></td>
</tr>
{% else %}
<tr><td colspan="3">Aucun log trouvé.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -16,7 +16,7 @@
{{ 'display.stats'|trans }}</a> {{ 'display.stats'|trans }}</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="https://openstreetmap.fr/contact/"> <a class="nav-link" href="{{ path('app_public_ask_for_help') }}">
<i class="bi bi-envelope-fill"></i> <i class="bi bi-envelope-fill"></i>
{{ 'display.contact_humans'|trans }}</a> {{ 'display.contact_humans'|trans }}</a>
</li> </li>