Compare commits

...

6 commits

Author SHA1 Message Date
Tykayn
ca00f8c0be affichage fraicheur des données 2025-06-19 12:49:30 +02:00
Tykayn
43139d50d9 float places 2025-06-19 11:48:07 +02:00
Tykayn
123bd8d4d1 plusieurs courbes de progression 2025-06-19 11:07:54 +02:00
Tykayn
d9219db84f courbes d'historique 2025-06-19 11:01:44 +02:00
Tykayn
06ced163e6 add mail action and view, unsubscription 2025-06-19 10:37:29 +02:00
Tykayn
dbe2f62c45 ajout view email proposé pour les commerçants 2025-06-19 10:20:40 +02:00
17 changed files with 911 additions and 56 deletions

View file

@ -20,7 +20,7 @@ body {
}
.filled {
background-color: #b0dfa0 !important;
background-color: rgba(0, 255, 0, 0.2) !important;
}
.filled:hover {

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 Version20250619074501 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 place ADD osm_data_date TIMESTAMP(0) WITHOUT TIME ZONE 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 place DROP osm_data_date
SQL);
}
}

View file

@ -0,0 +1,53 @@
<?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 Version20250619074657 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 place ADD osm_data_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD osm_data_date_min TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD osm_data_date_avg TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD osm_data_date_max TIMESTAMP(0) WITHOUT TIME ZONE 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 place DROP osm_data_date
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP osm_data_date_min
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP osm_data_date_avg
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP osm_data_date_max
SQL);
}
}

View file

@ -82,8 +82,11 @@ final class AdminController extends AbstractController
->setSiret($this->motocultrice->find_siret($placeData['tags']) ?? '')
->setAskedHumainsSupport(false)
->setLastContactAttemptDate(null)
->setNote('')
->setPlaceCount(0);
->setNote($this->motocultrice->find_tag($placeData['tags'], 'note') ? true : false)
->setNoteContent($this->motocultrice->find_tag($placeData['tags'], 'note') ?? '')
->setPlaceCount(0)
// ->setOsmData($placeData['modified'] ?? null)
;
// Mettre à jour les données depuis Overpass
$place->update_place_from_overpass_data($placeData);
@ -139,8 +142,74 @@ final class AdminController extends AbstractController
}
$stats->computeCompletionPercent();
$this->entityManager->persist($stats);
// Calculer les statistiques de fraîcheur des données OSM
$timestamps = [];
foreach ($stats->getPlaces() as $place) {
if ($place->getOsmDataDate()) {
$timestamps[] = $place->getOsmDataDate()->getTimestamp();
}
}
if (!empty($timestamps)) {
// Date la plus ancienne (min)
$minTimestamp = min($timestamps);
$stats->setOsmDataDateMin(new \DateTime('@' . $minTimestamp));
// Date la plus récente (max)
$maxTimestamp = max($timestamps);
$stats->setOsmDataDateMax(new \DateTime('@' . $maxTimestamp));
// Date moyenne
$avgTimestamp = array_sum($timestamps) / count($timestamps);
$stats->setOsmDataDateAvg(new \DateTime('@' . (int)$avgTimestamp));
}
if($stats->getDateCreated() == null) {
$stats->setDateCreated(new \DateTime());
}
$stats->setDateModified(new \DateTime());
// Créer un historique des statistiques
$statsHistory = new StatsHistory();
$statsHistory->setDate(new \DateTime())
->setStats($stats);
// Compter les Places avec email et SIRET
$placesWithEmail = 0;
$placesWithSiret = 0;
foreach ($stats->getPlaces() as $place) {
if ($place->getEmail() && $place->getEmail() !== '') {
$placesWithEmail++;
}
if ($place->getSiret() && $place->getSiret() !== '') {
$placesWithSiret++;
}
}
$statsHistory->setPlacesCount($stats->getPlaces()->count())
->setOpeningHoursCount($stats->getAvecHoraires())
->setAddressCount($stats->getAvecAdresse())
->setWebsiteCount($stats->getAvecSite())
->setSiretCount($placesWithSiret)
->setEmailsCount($placesWithEmail)
// ->setAccessibiliteCount($stats->getAvecAccessibilite())
// ->setNoteCount($stats->getAvecNote())
->setCompletionPercent($stats->getCompletionPercent())
->setStats($stats);
$this->entityManager->persist($statsHistory);
$this->entityManager->persist($stats);
$this->entityManager->flush();
$message = 'Labourage terminé avec succès. ' . $processedCount . ' nouveaux lieux traités.';
if ($updateExisting) {
$message .= ' ' . $updatedCount . ' lieux existants mis à jour pour la zone '.$stats->getName().' ('.$stats->getZone().').';
}
$this->addFlash('success', $message);
}
$this->entityManager->flush();
@ -175,15 +244,6 @@ final class AdminController extends AbstractController
$urls = $stats->getAllCTCUrlsMap();
$statsHistory = $this->entityManager->getRepository(StatsHistory::class)
->createQueryBuilder('sh')
->where('sh.stats = :stats')
->setParameter('stats', $stats)
->orderBy('sh.id', 'DESC')
->setMaxResults(365)
->getQuery()
->getResult();
// Calculer les statistiques
$calculatedStats = $this->motocultrice->calculateStats($commerces);
@ -206,9 +266,77 @@ final class AdminController extends AbstractController
$this->entityManager->flush();
$stats->computeCompletionPercent();
// Calculer les statistiques de fraîcheur des données OSM
$timestamps = [];
foreach ($stats->getPlaces() as $place) {
if ($place->getOsmDataDate()) {
$timestamps[] = $place->getOsmDataDate()->getTimestamp();
}
}
if (!empty($timestamps)) {
// Date la plus ancienne (min)
$minTimestamp = min($timestamps);
$stats->setOsmDataDateMin(new \DateTime('@' . $minTimestamp));
// Date la plus récente (max)
$maxTimestamp = max($timestamps);
$stats->setOsmDataDateMax(new \DateTime('@' . $maxTimestamp));
// Date moyenne
$avgTimestamp = array_sum($timestamps) / count($timestamps);
$stats->setOsmDataDateAvg(new \DateTime('@' . (int)$avgTimestamp));
}
if($stats->getDateCreated() == null) {
$stats->setDateCreated(new \DateTime());
}
$stats->setDateModified(new \DateTime());
// Créer un historique des statistiques
$statsHistory = new StatsHistory();
$statsHistory->setDate(new \DateTime())
->setStats($stats);
// Compter les Places avec email et SIRET
$placesWithEmail = 0;
$placesWithSiret = 0;
foreach ($stats->getPlaces() as $place) {
if ($place->getEmail() && $place->getEmail() !== '') {
$placesWithEmail++;
}
if ($place->getSiret() && $place->getSiret() !== '') {
$placesWithSiret++;
}
}
$statsHistory->setPlacesCount($stats->getPlaces()->count())
->setOpeningHoursCount($stats->getAvecHoraires())
->setAddressCount($stats->getAvecAdresse())
->setWebsiteCount($stats->getAvecSite())
->setSiretCount($placesWithSiret)
->setEmailsCount($placesWithEmail)
// ->setAccessibiliteCount($stats->getAvecAccessibilite())
// ->setNoteCount($stats->getAvecNote())
->setCompletionPercent($stats->getCompletionPercent())
->setStats($stats);
$this->entityManager->persist($statsHistory);
$this->entityManager->persist($stats);
$this->entityManager->flush();
$statsHistory = $this->entityManager->getRepository(StatsHistory::class)
->createQueryBuilder('sh')
->where('sh.stats = :stats')
->setParameter('stats', $stats)
->orderBy('sh.id', 'DESC')
->setMaxResults(365)
->getQuery()
->getResult();
return $this->render('admin/stats.html.twig', [
'stats' => $stats,
@ -269,6 +397,12 @@ final class AdminController extends AbstractController
#[Route('/admin/labourer/{insee_code}', name: 'app_admin_labourer')]
public function labourer(string $insee_code, bool $updateExisting = true): Response
{
// 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.');
return $this->redirectToRoute('app_public_index');
}
try {
// Récupérer ou créer les stats pour cette zone
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
@ -366,6 +500,28 @@ final class AdminController extends AbstractController
// Mettre à jour les statistiques finales
$stats->computeCompletionPercent();
// Calculer les statistiques de fraîcheur des données OSM
$timestamps = [];
foreach ($stats->getPlaces() as $place) {
if ($place->getOsmDataDate()) {
$timestamps[] = $place->getOsmDataDate()->getTimestamp();
}
}
if (!empty($timestamps)) {
// Date la plus ancienne (min)
$minTimestamp = min($timestamps);
$stats->setOsmDataDateMin(new \DateTime('@' . $minTimestamp));
// Date la plus récente (max)
$maxTimestamp = max($timestamps);
$stats->setOsmDataDateMax(new \DateTime('@' . $maxTimestamp));
// Date moyenne
$avgTimestamp = array_sum($timestamps) / count($timestamps);
$stats->setOsmDataDateAvg(new \DateTime('@' . (int)$avgTimestamp));
}
if($stats->getDateCreated() == null) {
$stats->setDateCreated(new \DateTime());
}
@ -377,11 +533,24 @@ final class AdminController extends AbstractController
$statsHistory->setDate(new \DateTime())
->setStats($stats);
// Compter les Places avec email et SIRET
$placesWithEmail = 0;
$placesWithSiret = 0;
foreach ($stats->getPlaces() as $place) {
if ($place->getEmail() && $place->getEmail() !== '') {
$placesWithEmail++;
}
if ($place->getSiret() && $place->getSiret() !== '') {
$placesWithSiret++;
}
}
$statsHistory->setPlacesCount($stats->getPlaces()->count())
->setOpeningHoursCount($stats->getAvecHoraires())
->setAddressCount($stats->getAvecAdresse())
->setWebsiteCount($stats->getAvecSite())
->setSiretCount($stats->getAvecSiret())
->setSiretCount($placesWithSiret)
->setEmailsCount($placesWithEmail)
// ->setAccessibiliteCount($stats->getAvecAccessibilite())
// ->setNoteCount($stats->getAvecNote())
->setCompletionPercent($stats->getCompletionPercent())
@ -514,4 +683,62 @@ final class AdminController extends AbstractController
return $response;
}
#[Route('/admin/make_email_for_place/{id}', name: 'app_admin_make_email_for_place')]
public function make_email_for_place(Place $place): Response
{
return $this->render('admin/view_email_for_place.html.twig', ['place' => $place]);
}
#[Route('/admin/no_more_sollicitation_for_place/{id}', name: 'app_admin_no_more_sollicitation_for_place')]
public function no_more_sollicitation_for_place(Place $place): Response
{
$place->setOptedOut(true);
$this->entityManager->persist($place);
$this->entityManager->flush();
$this->addFlash('success', 'Votre lieu '.$place->getName().' ne sera plus sollicité pour mettre à jour ses informations.');
return $this->redirectToRoute('app_public_index');
}
#[Route('/admin/send_email_to_place/{id}', name: 'app_admin_send_email_to_place')]
public function send_email_to_place(Place $place, \Symfony\Component\Mailer\MailerInterface $mailer): Response
{
// Vérifier si le lieu est opted out
if ($place->isOptedOut()) {
$this->addFlash('error', 'Ce lieu a demandé à ne plus être sollicité pour mettre à jour ses informations.');
return $this->redirectToRoute('app_public_index');
}
// Vérifier si le lieu a déjà été contacté
if ($place->getLastContactAttemptDate() !== null) {
$this->addFlash('error', 'Ce lieu a déjà été contacté le ' . $place->getLastContactAttemptDate()->format('d/m/Y H:i:s'));
return $this->redirectToRoute('app_public_index');
}
// Générer le contenu de l'email avec le template
$emailContent = $this->renderView('admin/email_content.html.twig', [
'place' => $place
]);
// Envoyer l'email
$email = (new \Symfony\Component\Mime\Email())
->from('contact@openstreetmap.fr')
->to('contact+send_email@cipherbliss.com')
->subject('Mise à jour des informations de votre établissement dans OpenStreetMap')
->html($emailContent);
$mailer->send($email);
// Mettre à jour la date de dernier contact
$place->setLastContactAttemptDate(new \DateTime());
$this->entityManager->persist($place);
$this->entityManager->flush();
$this->addFlash('success', 'Email envoyé avec succès à ' . $place->getName() . ' le ' . $place->getLastContactAttemptDate()->format('d/m/Y H:i:s'));
return $this->redirectToRoute('app_public_index');
}
}

View file

@ -103,6 +103,54 @@ class Place
#[ORM\Column(nullable: true)]
private ?int $habitants = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date = null;
#[ORM\Column(nullable: true)]
private ?int $osm_version = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $osm_user = null;
#[ORM\Column(nullable: true)]
private ?int $osm_uid = null;
#[ORM\Column(nullable: true)]
private ?int $osm_changeset = null;
public function getPlaceTypeName(): ?string
{
if ($this->main_tag == 'amenity=restaurant') {
return 'restaurant';
}
if ($this->main_tag == 'amenity=bar') {
return 'bar';
}
if ($this->main_tag == 'amenity=cafe') {
return 'café';
}
if ($this->main_tag == 'amenity=hotel') {
return 'hôtel';
}
if ($this->main_tag == 'amenity=supermarket') {
return 'supermarché';
}
if ($this->main_tag == 'amenity=pharmacy') {
return 'pharmacie';
}
if ($this->main_tag == 'amenity=bank') {
return 'banque';
}
if ($this->main_tag == 'amenity=post_office') {
return 'poste';
}
if ($this->main_tag == 'amenity=school') {
return 'école';
}
return 'établissement';
}
public function getMainTag(): ?string
{
return $this->main_tag;
@ -161,7 +209,7 @@ class Place
/**
* mettre à jour le lieu selon les tags osm
*/
public function update_place_from_overpass_data(array $overpass_data) {
public function update_place_from_overpass_data(array $overpass_data) {
if ( ! isset($overpass_data['tags']) || $overpass_data['tags'] == null) {
return;
@ -179,7 +227,8 @@ class Place
'addr:street' => '',
'website' => '',
'wheelchair' => '',
'note' => ''
'note' => '',
'fixme' => '',
], $overpass_data['tags'] );
@ -190,19 +239,41 @@ class Place
$this
->setOsmId( $orignal_overpass_data['id'])
->setOsmKind($orignal_overpass_data['type'] )
->setLat($orignal_overpass_data['lat'])
->setLon($orignal_overpass_data['lon'])
->setLat((float) $orignal_overpass_data['lat'])
->setLon((float) $orignal_overpass_data['lon'])
->setName(isset($overpass_data['name']) && $overpass_data['name'] != '' ? $overpass_data['name'] : null);
// Traiter le timestamp OSM si disponible
if (isset($orignal_overpass_data['timestamp']) && $orignal_overpass_data['timestamp']) {
try {
$osmDate = new \DateTime($orignal_overpass_data['timestamp']);
$this->setOsmDataDate($osmDate);
} catch (\Exception $e) {
// En cas d'erreur de parsing de la date, on ignore
}
}
// Traiter les autres métadonnées OSM
if (isset($orignal_overpass_data['version'])) {
$this->setOsmVersion($orignal_overpass_data['version']);
}
if (isset($orignal_overpass_data['user'])) {
$this->setOsmUser($orignal_overpass_data['user']);
}
if (isset($orignal_overpass_data['uid'])) {
$this->setOsmUid($orignal_overpass_data['uid']);
}
if (isset($orignal_overpass_data['changeset'])) {
$this->setOsmChangeset($orignal_overpass_data['changeset']);
}
$mapping = [
['key' => 'postcode', 'setter' => 'setZipCode', 'source' => $overpass_data],
['key' => 'email', 'setter' => 'setEmail', 'source' => $overpass_data],
['key' => 'opening_hours', 'setter' => 'setHasOpeningHours', 'source' => $overpass_data['tags'] ?? []],
['key' => 'note', 'setter' => 'setNote', 'source' => $overpass_data['tags'] ?? []],
['key' => 'addr:housenumber', 'setter' => 'setHasAddress', 'source' => $overpass_data['tags'] ?? []],
['key' => 'website', 'setter' => 'setHasWebsite', 'source' => $overpass_data['tags'] ?? []],
['key' => 'wheelchair', 'setter' => 'setHasWheelchair', 'source' => $overpass_data['tags'] ?? []],
['key' => 'note', 'setter' => 'setHasNote', 'source' => $overpass_data['tags'] ?? []],
['key' => 'siret', 'setter' => 'setSiret', 'source' => $overpass_data['tags'] ?? []],
['key' => 'addr:street', 'setter' => 'setStreet', 'source' => $overpass_data['tags'] ?? []],
['key' => 'addr:housenumber', 'setter' => 'setHousenumber', 'source' => $overpass_data['tags'] ?? []],
@ -214,6 +285,26 @@ class Place
}
}
// Traiter les notes et fixme
$noteContent = '';
$hasNote = false;
if (isset($orignal_overpass_data['tags']['note']) && $orignal_overpass_data['tags']['note'] !== '') {
$noteContent .= $orignal_overpass_data['tags']['note'];
$hasNote = true;
}
if (isset($orignal_overpass_data['tags']['fixme']) && $orignal_overpass_data['tags']['fixme'] !== '') {
if ($noteContent !== '') {
$noteContent .= "\n\n";
}
$noteContent .= "FIXME: " . $orignal_overpass_data['tags']['fixme'];
$hasNote = true;
}
$this->setNoteContent($noteContent);
$this->setHasNote($hasNote);
$this
// ->setOsmId($overpass_data['id'])
// ->setOsmKind($overpass_data['type'])
@ -226,8 +317,7 @@ class Place
->setHasOpeningHours($overpass_data['opening_hours'] ? true : false)
->setHasAddress($overpass_data['addr:housenumber'] && $overpass_data['addr:street'] ? true : false)
->setHasWebsite($overpass_data['website'] ? true : false)
->setHasWheelchair($overpass_data['wheelchair'] and $overpass_data['wheelchair'] != '' ? true : false)
->setHasNote($overpass_data['note'] and $overpass_data['note'] != '' ? true : false);
->setHasWheelchair($overpass_data['wheelchair'] and $overpass_data['wheelchair'] != '' ? true : false);
}
public function __construct()
@ -523,24 +613,24 @@ class Place
return $this;
}
public function getLat(): ?int
public function getLat(): ?float
{
return $this->lat;
}
public function setLat(?int $lat): static
public function setLat(?float $lat): static
{
$this->lat = $lat;
return $this;
}
public function getLon(): ?int
public function getLon(): ?float
{
return $this->lon;
}
public function setLon(?int $lon): static
public function setLon(?float $lon): static
{
$this->lon = $lon;
@ -594,4 +684,64 @@ class Place
return $this;
}
public function getOsmDataDate(): ?\DateTime
{
return $this->osm_data_date;
}
public function setOsmDataDate(?\DateTime $osm_data_date): static
{
$this->osm_data_date = $osm_data_date;
return $this;
}
public function getOsmVersion(): ?int
{
return $this->osm_version;
}
public function setOsmVersion(?int $osm_version): static
{
$this->osm_version = $osm_version;
return $this;
}
public function getOsmUser(): ?string
{
return $this->osm_user;
}
public function setOsmUser(?string $osm_user): static
{
$this->osm_user = $osm_user;
return $this;
}
public function getOsmUid(): ?int
{
return $this->osm_uid;
}
public function setOsmUid(?int $osm_uid): static
{
$this->osm_uid = $osm_uid;
return $this;
}
public function getOsmChangeset(): ?int
{
return $this->osm_changeset;
}
public function setOsmChangeset(?int $osm_changeset): static
{
$this->osm_changeset = $osm_changeset;
return $this;
}
}

View file

@ -86,6 +86,15 @@ class Stats
#[ORM\Column(nullable: true)]
private ?int $avec_name = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date_min = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date_avg = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date_max = null;
public function getCTCurlBase(): ?string
{
$base = 'https://complete-tes-commerces.fr/';
@ -493,6 +502,42 @@ class Stats
return $this;
}
public function getOsmDataDateMin(): ?\DateTime
{
return $this->osm_data_date_min;
}
public function setOsmDataDateMin(?\DateTime $osm_data_date_min): static
{
$this->osm_data_date_min = $osm_data_date_min;
return $this;
}
public function getOsmDataDateAvg(): ?\DateTime
{
return $this->osm_data_date_avg;
}
public function setOsmDataDateAvg(?\DateTime $osm_data_date_avg): static
{
$this->osm_data_date_avg = $osm_data_date_avg;
return $this;
}
public function getOsmDataDateMax(): ?\DateTime
{
return $this->osm_data_date_max;
}
public function setOsmDataDateMax(?\DateTime $osm_data_date_max): static
{
$this->osm_data_date_max = $osm_data_date_max;
return $this;
}
}

View file

@ -101,10 +101,10 @@ public function find_siret($tags) {
public function get_export_query($zone) {
return <<<QUERY
[out:csv(::id,::type,::lat,::lon,name,amenity,shop,office,healthcare,"contact:email",email,"contact:phone",phone,"contact:website",website,image,url,wikidata, opening_hours,"contact:housenumber","addr:housenumber","contact:street","addr:street",note,fixme,harassment_prevention,cuisine,brand,tourism,source,zip_code,"ref:FR:SIRET")];
[out:csv(::id,::type,::lat,::lon,::timestamp,::version,::user,::uid,::changeset,name,amenity,shop,office,healthcare,"contact:email",email,"contact:phone",phone,"contact:website",website,image,url,wikidata, opening_hours,"contact:housenumber","addr:housenumber","contact:street","addr:street",note,fixme,harassment_prevention,cuisine,brand,tourism,source,zip_code,"ref:FR:SIRET")];
{{geocodeArea:"{$zone}, France"}}->.searchArea;
{$this->overpass_base_places}
out skel qt;
out meta;
QUERY;
}
@ -112,7 +112,7 @@ QUERY;
return '[out:json][timeout:25];
area["ref:INSEE"="'.$zone.'"]->.searchArea;
'.$this->overpass_base_places.'
out center tags;';
out meta;';
}
private $more_tags = ['image', 'ref:FR:SIRET'];
@ -180,7 +180,14 @@ out center tags;';
'name' => $element['tags']['name'] ?? '',
'lat' => $element['lat'] ?? null,
'lon' => $element['lon'] ?? null,
'tags' => $element['tags']
'tags' => $element['tags'],
// Métadonnées OSM
'timestamp' => $element['timestamp'] ?? null,
'version' => $element['version'] ?? null,
'user' => $element['user'] ?? null,
'uid' => $element['uid'] ?? null,
'changeset' => $element['changeset'] ?? null,
'modified' => $element['timestamp'] ?? null
];
}
}
@ -216,6 +223,13 @@ out center tags;';
return null;
}
public function find_tag($tags, $tag) {
if(isset($tags[$tag]) && $tags[$tag] != '') {
return $tags[$tag];
}
return null;
}
public function get_city_osm_from_zip_code($zip_code) {
// Requête Overpass pour obtenir la zone administrative de niveau 8 avec un nom
$query = "[out:json][timeout:25];

View file

@ -0,0 +1,32 @@
<div class="content">
<i class="bi bi-shop-window"></i>
<p>Bonjour, votre {{place.getPlaceTypeName()}} "{{place.name }}" est présent dans la base de données mondiale OpenStreetMap (OSM) avec 650 000 autres en France.
<br>
Ces informations sont utilisées dans des milliers de sites web et annuaires, par Île de France mobilités, TomTom, Geovelo, Cartes IGN, Facebook, Instagram, Apple Plans et bien d'autres.
<br>
Plus les informations seront à jour et plus vous aurez de chances d'avoir des clients satisfaits.</p>
<p> Vous pouvez le modifier en cliquant sur le bouton ci-dessous, c'est gratuit et sans engagement.</p>
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': place.name != '' ? place.name : '?', 'uuid': place.uuidForUrl}) }}" class="btn btn-primary">
<i class="bi bi-pencil-square"></i>
Compléter les informations de mon commerce
</a>
<br>
<br>
Les bénévoles de l'association OpenStreetMap France ont mis en place cet outil pour faciliter la mise à jour des informations de vos commerces et améliorer la souveraineté numérique. Si vous avez besoin d'aide, n'hésitez pas à nous contacter à l'adresse <a href="mailto:contact@openstreetmap.fr">contact@openstreetmap.fr</a>.
<br>
<br>
Pour des besoins de prestation de services concernant l'intégration ou l'exportation de données depuis OSM, vous pouvez contacter la fédération des pros d'OpenStreetMap France sur <a href="https://fposm.fr">https://fposm.fr</a>.
<br>
<br>
En vous souhaitant une bonne journée.
<br>
- Les bénévoles de l'association OpenStreetMap France.
<br>
<hr>
<a href="{{ path('app_admin_commerce', {'id': place.id}) }}">Ne plus être sollicité pour mettre à jour mon commerce</a>
</div>

View file

@ -19,6 +19,28 @@
.completion-info {
margin-bottom: 2rem;
}
.osm-modification-info {
font-size: 0.85rem;
line-height: 1.3;
}
.osm-modification-info .text-muted {
font-size: 0.75rem;
}
.osm-modification-info a {
text-decoration: none;
color: #0d6efd;
}
.osm-modification-info a:hover {
text-decoration: underline;
}
.osm-freshness-info {
font-size: 0.95rem;
line-height: 1.4;
}
.osm-freshness-info .alert {
border-left: 4px solid #0dcaf0;
background-color: #f8f9fa;
}
</style>
{% endblock %}
@ -59,6 +81,74 @@
</div>
</div>
{% 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="col-md-3 col-12">
<span class="badge {% if stats.getCompletionPercent() > 85 %}bg-success{% else %}bg-warning{% endif %}">
@ -954,8 +1044,8 @@ window.updateMarkers = updateMarkers;
{# markClosedSiretsOnTable(); #}
function makeDonutGraphOfTags() {
// Récupérer tous les tags de la colonne 2
const tags = Array.from(document.querySelectorAll('table tbody tr td:nth-child(3)'))
// Récupérer tous les tags de la colonne 4 (Type)
const tags = Array.from(document.querySelectorAll('table tbody tr td:nth-child(4)'))
.map(cell => cell.textContent.trim())
.filter(tag => tag.includes('=')) // Filtrer les cellules qui ne contiennent pas de =
.filter(tag => tag); // Filtrer les cellules vides

View file

@ -10,6 +10,12 @@
{% endif %}
</a>
</td>
<td>
<a href="{{ path('app_admin_make_email_for_place', {'id': commerce.id}) }}">
voir email
<i class="bi bi-envelope-fill"></i>
</a>
</td>
<td class="text-right completion-cell"
style="background : rgba(0,255,0,{{ commerce.getCompletionPercentage() / 100 }})"
data-bs-toggle="popover"
@ -84,11 +90,67 @@
<td class="{{ commerce.hasWheelchair() ? 'filled' : '' }}">{{ commerce.wheelchair }}</td>
<td class="{{ commerce.hasNote() ? 'filled' : '' }}">{{ commerce.note }}</td>
<td class="{{ commerce.noteContent ? 'filled' : '' }}">{{ commerce.noteContent }}</td>
<td class="{{ commerce.siret ? 'filled' : '' }}"> <a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ commerce.siret }}" > {{ commerce.siret }}</a></td>
<td class="{{ commerce.siret ? 'filled' : '' }}">
{% if commerce.siret %}
{% set sirets = commerce.siret|split(';')|map(siret => siret|trim)|filter(siret => siret) %}
{% for siret in sirets %}
{% if not loop.first %}, {% endif %}
<a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ siret }}" >{{ siret }}</a>
{% endfor %}
{% else %}
<a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ commerce.siret }}" >
{% endif %}
<td>
{# (si siret clos) #}
</td>
<td>
{% if commerce.osmDataDate %}
{% set now = "now"|date("U") %}
{% set osmDate = commerce.osmDataDate|date("U") %}
{% set diff = now - osmDate %}
{% set years = (diff / 31536000)|round(0, 'floor') %}
{% set months = ((diff % 31536000) / 2592000)|round(0, 'floor') %}
{% set days = ((diff % 2592000) / 86400)|round(0, 'floor') %}
<div class="small osm-modification-info">
<div>
<i class="bi bi-calendar"></i>
{{ commerce.osmDataDate|date('d/m/Y H:i') }}
</div>
{% if commerce.osmUser %}
<div>
<i class="bi bi-person"></i>
<a href="https://www.openstreetmap.org/user/{{ commerce.osmUser }}" target="_blank" title="Voir le profil OSM">
{{ commerce.osmUser }}
</a>
</div>
{% endif %}
<div class="text-muted">
<small>
{% if diff < 86400 %}
Aujourd'hui
{% elseif years > 0 %}
{{ years }} an{{ years > 1 ? 's' : '' }}
{% if months > 0 %}, {{ months }} mois{% endif %}
{% elseif months > 0 %}
{{ months }} mois
{% if days > 0 %}, {{ days }} jour{{ days > 1 ? 's' : '' }}{% endif %}
{% elseif days > 0 %}
{{ days }} jour{{ days > 1 ? 's' : '' }}
{% else %}
Aujourd'hui
{% endif %}
</small>
</div>
</div>
{% else %}
<span class="text-muted">Non disponible</span>
{% endif %}
</td>
<td>
<a href="https://www.openstreetmap.org/{{ commerce.osmKind }}/{{ commerce.osmId }}" title="{{ commerce.osmKind }} - {{ commerce.osmId }} " >
<i class="bi bi-globe"></i>

View file

@ -2,6 +2,10 @@
<tr>
<th>Nom ({{ stats.places|length }})</th>
<th>
<i class="bi bi-envelope-fill"></i>
Email
</th>
<th>
<i class="bi bi-circle-fill"></i>
Completion %
</th>
@ -46,6 +50,10 @@
Siret clos
</th>
<th>
<i class="bi bi-clock-history"></i>
Dernière modif. OSM
</th>
<th>
Osm id</th>
<th>

View file

@ -11,33 +11,143 @@
document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('completionHistoryChart').getContext('2d');
// Préparer les données pour chaque aspect
const labels = [
{% for stat in statsHistory|reverse %}
'{{ stat.date|date('d/m/Y') }}'{% if not loop.last %},{% endif %}
{% endfor %}
];
const completionData = [
{% for stat in statsHistory|reverse %}
{{ stat.completionPercent }}{% if not loop.last %},{% endif %}
{% endfor %}
];
const openingHoursData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.openingHoursCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const addressData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.addressCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const websiteData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.websiteCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const siretData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.siretCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const emailData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.emailsCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
new Chart(ctx, {
type: 'line',
data: {
labels: [
{% for stat in statsHistory %}
'{{ stat.date|date('d/m/Y') }}'{% if not loop.last %},{% endif %}
{% endfor %}
],
datasets: [{
label: 'Taux de complétion (%)',
data: [
{% for stat in statsHistory %}
{{ stat.completionPercent }}{% if not loop.last %},{% endif %}
{% endfor %}
],
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.3,
fill: true
}]
labels: labels,
datasets: [
{
label: 'Taux de complétion global (%)',
data: completionData,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 3
},
{
label: 'Horaires d\'ouverture (%)',
data: openingHoursData,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'Adresses (%)',
data: addressData,
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'Sites web (%)',
data: websiteData,
borderColor: 'rgb(255, 205, 86)',
backgroundColor: 'rgba(255, 205, 86, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'SIRET (%)',
data: siretData,
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'Emails (%)',
data: emailData,
borderColor: 'rgb(199, 199, 199)',
backgroundColor: 'rgba(199, 199, 199, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
}
]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Évolution du taux de complétion au fil du temps'
text: 'Évolution des taux de complétion par aspect au fil du temps'
},
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20
}
}
},
scales: {
@ -46,7 +156,7 @@ document.addEventListener('DOMContentLoaded', function() {
max: 100,
title: {
display: true,
text: 'Complétion (%)'
text: 'Pourcentage (%)'
}
},
x: {
@ -55,6 +165,10 @@ document.addEventListener('DOMContentLoaded', function() {
text: 'Date'
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
}
});

View file

@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}
{% block title %}Email pour {{ place.name }}{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Email pour {{ place.name }}</h1>
<div class="content">
{% include 'admin/email_content.html.twig' with {'place': place} %}
</div>
</div>
<div class="container">
<div class="row">
<div class="col-12">
<a href="{{ path('app_admin_send_email_to_place', {'id': place.id}) }}" class="btn btn-primary">
<i class="bi bi-envelope-fill"></i>
Envoyer l'email
</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -36,7 +36,7 @@
</div>
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible fade show mt-3" role="alert">
<div class="alert alert-{{ label }} is-{{ label }} alert-dismissible fade show mt-3" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>

View file

@ -97,6 +97,7 @@
>
<i class="bi bi-recycle"></i>
</a>
<a href="{{ path('app_admin_delete_by_zone', {'insee_code': stat.zone}) }}"
class="btn btn-sm btn-danger"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette zone ?')"

View file

@ -128,7 +128,7 @@
{% block javascripts %}
{{ parent() }}
<script src='{{ asset('js/utils.js') }}'></script>
{# <script src='{{ asset('js/utils.js') }}'></script> #}
<script type="module">
import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}';
document.addEventListener('DOMContentLoaded', function() {

View file

@ -9,8 +9,9 @@
<thead>
<tr>
<th>Nom</th>
<th>Code postal</th>
<th>Code insee</th>
<th>Note</th>
<th>contenu de note</th>
<th>Actions</th>
</tr>
</thead>
@ -25,6 +26,7 @@
<td>
{{place.zipcode}}
</td>
<td>{{ place.note }}</td>
<td>{{ place.noteContent }}</td>
<td><a class="btn btn-primary" href="{{ path('app_admin_commerce', {'id': place.id}) }}">
<i class="bi bi-pencil"></i>