compute stats for completion by zone, have base tags, split categories

This commit is contained in:
Tykayn 2025-05-26 23:51:46 +02:00 committed by tykayn
parent f15fec6d18
commit f69b7824af
16 changed files with 1257 additions and 118 deletions

View file

@ -0,0 +1,59 @@
<?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 Version20250526200731 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 has_opening_hours BOOLEAN DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ADD has_address BOOLEAN DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ADD has_website BOOLEAN DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ADD has_wheelchair BOOLEAN DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place ADD has_note BOOLEAN 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 has_opening_hours
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place DROP has_address
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place DROP has_website
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place DROP has_wheelchair
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE place DROP has_note
SQL);
}
}

View file

@ -0,0 +1,59 @@
<?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 Version20250526203604 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 stats ADD avec_horaires SMALLINT NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD avec_adresse SMALLINT NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD avec_site SMALLINT NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD avec_accessibilite SMALLINT NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD avec_note SMALLINT NOT 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 stats DROP avec_horaires
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP avec_adresse
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP avec_site
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP avec_accessibilite
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP avec_note
SQL);
}
}

View file

@ -0,0 +1,328 @@
:root {
--primary-color: #2c3e50;
--secondary-color: #3498db;
--accent-color: #e74c3c;
--text-color: #333;
--light-gray: #f5f6fa;
--border-color: #dcdde1;
--success-color: #27ae60;
--warning-color: #f1c40f;
--error-color: #c0392b;
}
/* Reset et styles de base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--light-gray);
}
/* Layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Navigation */
.navbar {
background-color: var(--primary-color);
padding: 1rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
color: white;
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.navbar-menu {
display: flex;
gap: 1.5rem;
}
.navbar-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.navbar-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Cards */
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-title {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--primary-color);
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-control:focus {
outline: none;
border-color: var(--secondary-color);
}
/* Buttons */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
}
.btn-primary {
background-color: var(--secondary-color);
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.btn-secondary {
background-color: var(--primary-color);
color: white;
}
.btn-secondary:hover {
background-color: #2c3e50;
transform: translateY(-1px);
}
/* Alerts */
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-success {
background-color: #d4edda;
color: var(--success-color);
border: 1px solid #c3e6cb;
}
.alert-warning {
background-color: #fff3cd;
color: var(--warning-color);
border: 1px solid #ffeeba;
}
.alert-error {
background-color: #f8d7da;
color: var(--error-color);
border: 1px solid #f5c6cb;
}
/* Grid */
.grid {
display: grid;
gap: 1.5rem;
}
.grid-2 {
grid-template-columns: repeat(2, 1fr);
}
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
.grid-4 {
grid-template-columns: repeat(4, 1fr);
}
/* Responsive */
@media (max-width: 768px) {
.grid-2,
.grid-3,
.grid-4 {
grid-template-columns: 1fr;
}
.navbar-menu {
display: none;
}
.navbar-menu.active {
display: flex;
flex-direction: column;
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: var(--primary-color);
padding: 1rem;
}
}
/* Utilities */
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.text-left {
text-align: left;
}
.mt-1 {
margin-top: 0.5rem;
}
.mt-2 {
margin-top: 1rem;
}
.mt-3 {
margin-top: 1.5rem;
}
.mt-4 {
margin-top: 2rem;
}
.mb-1 {
margin-bottom: 0.5rem;
}
.mb-2 {
margin-bottom: 1rem;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.mb-4 {
margin-bottom: 2rem;
}
.p-1 {
padding: 0.5rem;
}
.p-2 {
padding: 1rem;
}
.p-3 {
padding: 1.5rem;
}
.p-4 {
padding: 2rem;
}
/* Map container */
.map-container {
width: 100%;
height: 400px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 1.5rem;
}
/* Tags display */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1rem 0;
}
.tag {
background-color: var(--light-gray);
padding: 0.25rem 0.75rem;
border-radius: 16px;
font-size: 0.875rem;
color: var(--primary-color);
border: 1px solid var(--border-color);
}
/* Footer */
.footer {
background-color: var(--primary-color);
color: white;
padding: 2rem 0;
margin-top: 3rem;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-links {
display: flex;
gap: 1.5rem;
}
.footer-link {
color: white;
text-decoration: none;
opacity: 0.8;
transition: opacity 0.3s;
}
.footer-link:hover {
opacity: 1;
}

View file

@ -6,7 +6,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use App\Entity\Place;
use App\Entity\Stats;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use function uuid_create;
@ -29,6 +29,48 @@ final class AdminController extends AbstractController
]);
}
#[Route('/admin/stats/{zip_code}', name: 'app_admin_stats')]
public function calculer_stats(string $zip_code): Response
{
// Récupérer tous les commerces de la zone
$commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]);
// Récupérer les stats existantes pour la zone
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]);
if(!$stats) {
$stats = new Stats();
$stats->setZone($zip_code);
}
// Calculer les statistiques
$calculatedStats = $this->motocultrice->calculateStats($commerces);
// Mettre à jour les stats pour la zone donnée
$stats->setPlacesCount($calculatedStats['places_count']);
$stats->setAvecHoraires($calculatedStats['counters']['avec_horaires']);
$stats->setAvecAdresse($calculatedStats['counters']['avec_adresse']);
$stats->setAvecSite($calculatedStats['counters']['avec_site']);
$stats->setAvecAccessibilite($calculatedStats['counters']['avec_accessibilite']);
$stats->setAvecNote($calculatedStats['counters']['avec_note']);
$stats->setCompletionPercent($calculatedStats['completion_percent']);
// Associer les stats à chaque commerce
foreach ($commerces as $commerce) {
$commerce->setStats($stats);
$this->entityManager->persist($commerce);
}
$this->entityManager->persist($stats);
$this->entityManager->flush();
return $this->render('admin/stats.html.twig', [
'stats' => $stats,
'zip_code' => $zip_code,
'counters' => $calculatedStats['counters']
]);
}
#[Route('/admin/labourer/{zip_code}', name: 'app_admin_labourer')]
public function labourer_zone(string $zip_code): Response
{
@ -39,10 +81,71 @@ final class AdminController extends AbstractController
// Récupérer les commerces existants dans la base de données pour cette zone
$commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]);
// Récupérer ou créer les stats pour cette zone
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]);
if (!$stats) {
$stats = new Stats();
$stats->setZone($zip_code);
// for commerce, set stats
foreach ($commerces as $commerce) {
$commerce->setStats($stats);
$this->entityManager->persist($commerce);
$stats->addPlace($commerce);
}
// rebuild et persist
$stats->computeCompletionPercent();
$this->entityManager->persist($stats);
$this->entityManager->flush();
}
// Initialiser les compteurs
$counters = [
'avec_horaires' => 0,
'avec_adresse' => 0,
'avec_site' => 0,
'avec_accessibilite' => 0,
'avec_note' => 0
];
// Compter les différents critères pour chaque commerce
foreach ($commerces as $commerce) {
if ($commerce->hasOpeningHours()) {
$counters['avec_horaires']++;
}
if ($commerce->hasAddress()) {
$counters['avec_adresse']++;
}
if ($commerce->hasWebsite()) {
$counters['avec_site']++;
}
if ($commerce->hasWheelchair()) {
$counters['avec_accessibilite']++;
}
if ($commerce->hasNote()) {
$counters['avec_note']++;
}
$commerce->setStats($stats);
}
// Mettre à jour les statistiques
$stats->setPlacesCount(count($commerces));
$stats->setAvecHoraires($counters['avec_horaires']);
$stats->setAvecAdresse($counters['avec_adresse']);
$stats->setAvecSite($counters['avec_site']);
$stats->setAvecAccessibilite($counters['avec_accessibilite']);
$stats->setAvecNote($counters['avec_note']);
$stats->computeCompletionPercent();
$this->entityManager->persist($stats);
$this->entityManager->flush();
$osm_object_ids = [];
if ($commerces) {
if ($commerces) {
// Extraire les osm_object_ids des commerces existants
$osm_object_ids = array_map(function($commerce) {
return $commerce->getOsmId();
@ -53,7 +156,7 @@ final class AdminController extends AbstractController
$results = array_filter($results, function($commerce) use ($osm_object_ids) {
return !in_array($commerce['id'], $osm_object_ids);
});
// on crée un commerce pour chaque résultat qui reste
foreach ($results as $result) {
$commerce = new Place();
@ -65,11 +168,18 @@ final class AdminController extends AbstractController
->setUuidForUrl($this->motocultrice->uuid_create())
->setOptedOut(false)
->setDead(false)
->setNote($result['note'] ?? null)
->setModifiedDate(new \DateTime())
->setAskedHumainsSupport(false)
->setLastContactAttemptDate(null)
->setStats(null);
->setStats(null)
->setNote($result['tags'] && isset($result['tags']['note']) ? isset($result['tags']['note']) : null)
->setHasOpeningHours($result['tags'] && isset($result['tags']['opening_hours']) ? isset($result['tags']['opening_hours']) : null)
->setHasAddress(($result['tags'] && isset($result['tags']['address']) || $result['tags'] && isset($result['tags']['contact:address'])) ? isset($result['tags']['address']) : null)
->setHasWebsite($result['tags'] && isset($result['tags']['website']) ? $result['tags']['website'] : null)
->setHasWheelchair($result['tags'] && isset($result['tags']['wheelchair']) ? $result['tags']['wheelchair'] : null)
->setHasNote($result['tags'] && isset($result['tags']['note']) ? $result['tags']['note'] : null)
;
$this->entityManager->persist($commerce);
}
@ -77,7 +187,99 @@ final class AdminController extends AbstractController
return $this->render('admin/labourage_results.html.twig', [
'results' => $results,
'commerces' => $commerces,
'zone' => $zip_code,
]);
}
#[Route('/admin/delete/{id}', name: 'app_admin_delete')]
public function delete(int $id): Response
{
$commerce = $this->entityManager->getRepository(Place::class)->find($id);
$name = $commerce->getName();
$this->entityManager->remove($commerce);
$this->entityManager->flush();
$this->addFlash('success', 'Le lieu '.$name.' a été supprimé avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.');
return $this->redirectToRoute('app_admin_dashboard');
}
#[Route('/admin/delete_by_zone/{zip_code}', name: 'app_admin_delete_by_zone')]
public function delete_by_zone(string $zip_code): Response
{
$commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]);
foreach ($commerces as $commerce) {
$this->entityManager->remove($commerce);
}
$this->entityManager->flush();
$this->addFlash('success', 'Tous les commerces de la zone '.$zip_code.' ont été supprimés avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.');
return $this->redirectToRoute('app_admin_dashboard');
}
#[Route('/admin/export', name: 'app_admin_export')]
public function export(): Response
{
$places = $this->entityManager->getRepository(Place::class)->findAll();
$csvData = [];
$csvData[] = [
'Nom',
'Email',
'Code postal',
'ID OSM',
'Type OSM',
'Date de modification',
'Date dernier contact',
'Note',
'Désabonné',
'Inactif',
'Support humain demandé',
'A des horaires',
'A une adresse',
'A un site web',
'A accessibilité',
'A une note'
];
foreach ($places as $place) {
$csvData[] = [
$place->getName(),
$place->getEmail(),
$place->getZipCode(),
$place->getOsmId(),
$place->getOsmKind(),
$place->getModifiedDate() ? $place->getModifiedDate()->format('Y-m-d H:i:s') : '',
$place->getLastContactAttemptDate() ? $place->getLastContactAttemptDate()->format('Y-m-d H:i:s') : '',
$place->getNote(),
$place->isOptedOut() ? 'Oui' : 'Non',
$place->isDead() ? 'Oui' : 'Non',
$place->isAskedHumainsSupport() ? 'Oui' : 'Non',
$place->hasOpeningHours() ? 'Oui' : 'Non',
$place->hasAddress() ? 'Oui' : 'Non',
$place->hasWebsite() ? 'Oui' : 'Non',
$place->hasWheelchair() ? 'Oui' : 'Non',
$place->hasNote() ? 'Oui' : 'Non'
];
}
$response = new Response();
$response->headers->set('Content-Type', 'text/csv');
$response->headers->set('Content-Disposition', 'attachment; filename="export_places.csv"');
$handle = fopen('php://temp', 'r+');
foreach ($csvData as $row) {
fputcsv($handle, $row, ';');
}
rewind($handle);
$response->setContent(stream_get_contents($handle));
fclose($handle);
return $response;
}
}

View file

@ -40,12 +40,31 @@ class PublicController extends AbstractController
{
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]);
if (!$place) {
$this->addFlash('warning', 'Ce lien de modification n\'existe pas.');
return $this->redirectToRoute('app_public_index');
}
$commerce = $this->motocultrice->get_osm_object_data($place->getOsmKind(), $place->getOsmId());
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());
// 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']);
return $this->render('public/edit.html.twig', [
'commerce' => $commerce,
'commerce_overpass' => $commerce_overpass,
'name' => $name,
'commerce' => $place,
'osm_kind' => $place->getOsmKind(),
"mapbox_token" => $_ENV['MAPBOX_TOKEN'],
"maptiler_token" => $_ENV['MAPTILER_TOKEN'],
@ -57,7 +76,8 @@ class PublicController extends AbstractController
{
// get stats
$stats = $this->entityManager->getRepository(Stats::class)->findAll();
$places = $this->entityManager->getRepository(Place::class)->findAll();
$places = $this->entityManager->getRepository(Place::class)->findBy([], ['zip_code' => 'ASC', 'name' => 'ASC']);
return $this->render('public/dashboard.html.twig', [
'controller_name' => 'PublicController',
'stats' => $stats,
@ -108,6 +128,9 @@ class PublicController extends AbstractController
// Récupérer le token OSM depuis les variables d'environnement
$osm_api_token = $_ENV['APP_OSM_BEARER'];
$exception = false;
$exception_message = "";
try {
$client = new Client();
@ -186,6 +209,8 @@ class PublicController extends AbstractController
}
} catch (\Exception $e) {
$status = "Erreur lors de la communication avec l'API OSM: " . $e->getMessage();
$exception = true;
$exception_message = $e->getMessage();
// Debug de la réponse en cas d'erreur
if (method_exists($e, 'getResponse')) {
var_dump($e->getResponse()->getBody()->getContents());
@ -199,6 +224,8 @@ class PublicController extends AbstractController
'controller_name' => 'PublicController',
'commerce' => $commerce,
'status' => $status,
'exception' => $exception,
'exception_message' => $exception_message,
'mapbox_token' => $_ENV['MAPBOX_TOKEN'],
'maptiler_token' => $_ENV['MAPTILER_TOKEN'],
]);

View file

@ -21,7 +21,7 @@ class HistoryFixtures extends Fixture
// Créer quelques places de test
for ($i = 0; $i < 15; $i++) {
$place = new Place();
$place->setName($faker->company)
$place->setName($faker->company . ' (mock)')
->setUuidForUrl($faker->uuid)
->setOsmId((string)$faker->numberBetween(1000000, 9999999))
->setOsmKind($faker->randomElement(['node', 'way', 'relation']))
@ -48,7 +48,7 @@ class HistoryFixtures extends Fixture
// Créer des statistiques de test
for ($i = 0; $i < 3; $i++) {
$stat = new Stats();
$stat->setZone($faker->city)
$stat->setZone($faker->city . ' (mock)')
->setCompletionPercent($faker->numberBetween(0, 100))
->addPlace( $faker->randomElement($places_list))
->addPlace( $faker->randomElement($places_list))

View file

@ -61,6 +61,21 @@ class Place
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(nullable: true)]
private ?bool $has_opening_hours = null;
#[ORM\Column(nullable: true)]
private ?bool $has_address = null;
#[ORM\Column(nullable: true)]
private ?bool $has_website = null;
#[ORM\Column(nullable: true)]
private ?bool $has_wheelchair = null;
#[ORM\Column(nullable: true)]
private ?bool $has_note = null;
public function __construct()
{
$this->histories = new ArrayCollection();
@ -256,4 +271,77 @@ class Place
return $this;
}
public function hasOpeningHours(): ?bool
{
return $this->has_opening_hours;
}
public function setHasOpeningHours(?bool $has_opening_hours): static
{
$this->has_opening_hours = $has_opening_hours;
return $this;
}
public function hasAddress(): ?bool
{
return $this->has_address;
}
public function setHasAddress(?bool $has_address): static
{
$this->has_address = $has_address;
return $this;
}
public function hasWebsite(): ?bool
{
return $this->has_website;
}
public function setHasWebsite(?bool $has_website): static
{
$this->has_website = $has_website;
return $this;
}
public function hasWheelchair(): ?bool
{
return $this->has_wheelchair;
}
public function setHasWheelchair(?bool $has_wheelchair): static
{
$this->has_wheelchair = $has_wheelchair;
return $this;
}
public function hasNote(): ?bool
{
return $this->has_note;
}
public function setHasNote(?bool $has_note): static
{
$this->has_note = $has_note;
return $this;
}
public function getPlaceCount(): ?int
{
return $this->place_count;
}
public function setPlaceCount(int $place_count): static
{
$this->place_count = $place_count;
return $this;
}
}

View file

@ -28,9 +28,54 @@ class Stats
#[ORM\OneToMany(targetEntity: Place::class, mappedBy: 'stats')]
private Collection $places;
#[ORM\Column(type: Types::SMALLINT)]
// nombre de commerces dans la zone
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $places_count = null;
// nombre de commerces avec horaires
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_horaires = null;
// nombre de commerces avec adresse
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_adresse = null;
// nombre de commerces avec site
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_site = null;
// nombre de commerces avec accessibilité
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_accessibilite = null;
// nombre de commerces avec note
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_note = null;
// calcule le pourcentage de complétion de la zone
public function computeCompletionPercent(): ?int
{
// Si aucun commerce, on retourne 0
if ($this->places_count === 0 || $this->places_count === null) {
$this->setCompletionPercent(0);
return 0;
}
// On prend le maximum entre les différents critères
$max = max(
$this->avec_horaires ?? 0,
$this->avec_adresse ?? 0,
$this->avec_site ?? 0,
$this->avec_accessibilite ?? 0,
$this->avec_note ?? 0
);
$computed = round(($max) / $this->places_count * 100);
$this->setCompletionPercent($computed);
return $this->completion_percent;
}
public function __construct()
{
$this->places = new ArrayCollection();
@ -106,4 +151,66 @@ class Stats
return $this;
}
}
public function getAvecHoraires(): ?int
{
return $this->avec_horaires;
}
public function setAvecHoraires(int $avec_horaires): static
{
$this->avec_horaires = $avec_horaires;
return $this;
}
public function getAvecAdresse(): ?int
{
return $this->avec_adresse;
}
public function setAvecAdresse(int $avec_adresse): static
{
$this->avec_adresse = $avec_adresse;
return $this;
}
public function getAvecSite(): ?int
{
return $this->avec_site;
}
public function setAvecSite(int $avec_site): static
{
$this->avec_site = $avec_site;
return $this;
}
public function getAvecAccessibilite(): ?int
{
return $this->avec_accessibilite;
}
public function setAvecAccessibilite(int $avec_accessibilite): static
{
$this->avec_accessibilite = $avec_accessibilite;
return $this;
}
public function getAvecNote(): ?int
{
return $this->avec_note;
}
public function setAvecNote(int $avec_note): static
{
$this->avec_note = $avec_note;
return $this;
}
}

View file

@ -10,12 +10,26 @@ class Motocultrice
private $overpassApiUrl = 'https://overpass-api.de/api/interpreter';
private $osmApiUrl = 'https://www.openstreetmap.org/api/0.6';
public $base_tags = [
'name',
'opening_hours',
'contact:email',
'contact:phone',
'wheelchair',
'addr:housenumber',
'addr:street',
'contact:website',
'contact:mastodon',
// 'EEEEEEEEEEEEEEEEEEE'
];
private $more_tags = ['image', 'ref:FR:SIRET'];
public function __construct(
private HttpClientInterface $client,
private EntityManagerInterface $entityManager
) {
}
public function labourer(string $zone): array
{
if (!$zone) {
@ -58,6 +72,9 @@ QUERY;
if (isset($data['elements'])) {
foreach ($data['elements'] as $element) {
if (isset($element['tags'])) {
$email = $element['tags']['contact:email'] ?? $element['tags']['email'] ?? null;
// On passe si pas d'email
if (!$email) {
@ -86,6 +103,8 @@ QUERY;
public function get_osm_object_data($osm_kind = 'node', $osm_object_id = 12855459190)
{
$object_id = "https://www.openstreetmap.org/api/0.6/".$osm_kind."/".$osm_object_id;
// dump($object_id);
// die();
try {
$response = $this->client->request('GET', $object_id);
@ -96,8 +115,8 @@ QUERY;
throw new \Exception("Impossible de récupérer les données OSM : " . $e->getMessage());
}
// convertir les tags en clés et valeurs
$osm_object_data['tags_converted'] = [];
// convertir les tags en clés et valeurs, remplir avec les tags de base
$osm_object_data['tags_converted'] = $this->base_tags;
// Initialiser le tableau des tags convertis
if (isset($osm_object_data['node'])) {
$osm_object_data['node']['tags_converted'] = [];
@ -236,4 +255,48 @@ QUERY;
throw new \Exception("Erreur lors de la communication avec l'API OSM : " . $e->getMessage());
}
}
public function calculateStats(array $places): array
{
$counters = [
'avec_horaires' => 0,
'avec_adresse' => 0,
'avec_site' => 0,
'avec_accessibilite' => 0,
'avec_note' => 0
];
foreach ($places as $place) {
if ($place->hasOpeningHours()) {
$counters['avec_horaires']++;
}
if ($place->hasAddress()) {
$counters['avec_adresse']++;
}
if ($place->hasWebsite()) {
$counters['avec_site']++;
}
if ($place->hasWheelchair()) {
$counters['avec_accessibilite']++;
}
if ($place->hasNote()) {
$counters['avec_note']++;
}
}
$totalPlaces = count($places);
$completionPercent = 0;
if ($totalPlaces > 0) {
$totalCriteria = 5; // nombre total de critères
$totalCompleted = array_sum($counters);
$completionPercent = round(($totalCompleted / ($totalPlaces * $totalCriteria)) * 100);
}
return [
'places_count' => $totalPlaces,
'completion_percent' => $completionPercent,
'counters' => $counters
];
}
}

View file

@ -11,15 +11,37 @@
<div class="example-wrapper">
<h1>Labourage fait sur la zone "{{ zone }}" ✅</h1>
<p>
lieux trouvés en plus: {{ results|length }}
</p>
{# {{ dump(results) }} #}
{% for commerce in results %}
<p>
{{ commerce.name }}
</p>
<pre>
{{ dump(commerce) }}
</pre>
<hr>
<p>
commerces existants disposant d'un moyen de contact mail: {{ commerces|length }}
</p>
{# {{ dump(commerces[0]) }} #}
<ul>
{% for commerce in commerces %}
<li>
{% if commerce.name is not null %}
{{ commerce.name }},
{% else %}
un lieu sans nom
{% endif %}
{# {{ commerce.note }},
{{ commerce.has_address }},
{{ commerce.has_website }},
{{ commerce.has_wheelchair }},
{{ commerce.has_note }} #}
<a href="https://www.openstreetmap.org/{{ commerce.getOsmKind() }}/{{ commerce.getOsmId() }}" target="_blank">voir sur osm</a>
</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View file

@ -0,0 +1,192 @@
{% extends 'base.html.twig' %}
{% block title %}{{ 'display.stats'|trans }}{% endblock %}
{% block body %}
<div class="container">
<div class="card mt-4 p-4">
<h1 class="card-title">{{ 'display.stats'|trans }}</h1>
<p>
{{ stats.zone }}
</p>
<p>
{# {{ dump(stats) }} #}
</p>
{{ stats.getCompletionPercent() }} % complété sur les critères donnés.
<br>
{{ stats.getPlacesCount() }} commerces dans la zone.
<br>
{{ stats.getAvecHoraires() }} commerces avec horaires.
<br>
{{ stats.getAvecAdresse() }} commerces avec adresse.
<br>
{{ stats.getAvecSite() }} commerces avec site web renseigné.
<br>
{{ stats.getAvecAccessibilite() }} commerces avec accessibilité renseignée.
<br>
{{ stats.getAvecNote() }} commerces avec note renseignée.
<br>
</div>
<div class="card mt-4">
<h1 class="card-title">Tableau des commerces</h1>
<table class="table table-bordered">
<thead>
<tr>
<th>Nom ({{ stats.getPlacesCount() }})</th>
<th>Adresse ({{ stats.getAvecAdresse() }} / {{ stats.getPlacesCount() }})</th>
<th>Site web ({{ stats.getAvecSite() }} / {{ stats.getPlacesCount() }})</th>
<th>Accessibilité ({{ stats.getAvecAccessibilite() }} / {{ stats.getPlacesCount() }})</th>
<th>Note ({{ stats.getAvecNote() }} / {{ stats.getPlacesCount() }})</th>
</tr>
</thead>
<tbody>
{% for commerce in stats.places %}
<tr>
<td style="background-color: {{ commerce.hasAddress() ? 'yellowgreen' : 'transparent' }};">{{ commerce.name }}</td>
<td style="background-color: {{ commerce.hasAddress() ? 'yellowgreen' : 'transparent' }};">{{ commerce.address }}</td>
<td style="background-color: {{ commerce.hasWebsite() ? 'yellowgreen' : 'transparent' }};">{{ commerce.website }}</td>
<td style="background-color: {{ commerce.hasWheelchair() ? 'yellowgreen' : 'transparent' }};">{{ commerce.wheelchair }}</td>
<td style="background-color: {{ commerce.hasNote() ? 'yellowgreen' : 'transparent' }};">{{ commerce.note }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<style>
/* Styles spécifiques pour la page stats */
.stats-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background-color: var(--light-gray);
border-radius: 4px;
}
.stat-label {
font-weight: 500;
color: var(--primary-color);
}
.stat-value {
font-size: 1.25rem;
font-weight: bold;
color: var(--secondary-color);
}
.modifications-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.modification-item {
padding: 0.75rem;
background-color: var(--light-gray);
border-radius: 4px;
}
.modification-header {
margin-bottom: 0.25rem;
}
.modification-date {
font-size: 0.875rem;
color: var(--text-color);
opacity: 0.8;
}
.modification-details {
display: flex;
justify-content: space-between;
align-items: center;
}
.modification-type {
font-weight: 500;
color: var(--primary-color);
}
.shop-types-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.shop-type-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background-color: var(--light-gray);
border-radius: 4px;
}
.shop-type-name {
font-weight: 500;
}
.shop-type-count {
background-color: var(--secondary-color);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.875rem;
}
.places-table-container {
overflow-x: auto;
}
.places-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.places-table th,
.places-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.places-table th {
background-color: var(--light-gray);
font-weight: 500;
color: var(--primary-color);
}
.places-table tr:hover {
background-color: var(--light-gray);
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.grid-3 {
grid-template-columns: 1fr;
}
.places-table {
font-size: 0.875rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
}
}
</style>
{% endblock %}

View file

@ -10,6 +10,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
{# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #}
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}
@ -17,6 +18,7 @@
<body>
<header class="main-header">
<div class="container">
<div class="row">
<div class="col-12">
<a href="{{ path('app_public_index') }}">
@ -24,6 +26,38 @@
</a>
</div>
</div>
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endfor %}
<div class="row">
<div class="col-12">
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4 rounded shadow-sm">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="{{ path('app_public_index') }}">{{ 'display.home'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_public_dashboard') }}">{{ 'display.stats'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://openstreetmap.fr/contact/">{{ 'display.contact_humans'|trans }}</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
</div>
</header>
<div class="body-landing">
@ -34,7 +68,9 @@
<div class="row">
<div class="col-12">
<p>OpenStreetMap Mon Commerce</p>
<p>Licence AGPLv3+, fait par CipherBliss, membre de la fédération des professionels d'OpenStreetMap, Sources des données : <a href="https://www.openstreetmap.org/">OpenStreetMap</a>.
<p>Licence AGPLv3+,
fait par Tykayn de
<a href="https://www.cipherbliss.com">CipherBliss EI</a>, membre de la fédération des professionels d'OpenStreetMap, Sources des données : <a href="https://www.openstreetmap.org/">OpenStreetMap</a>.
<br> <a href="https://www.openstreetmap.org/copyright">OpenStreetMap France</a></p>
</div>
</div>

View file

@ -18,30 +18,7 @@
{% block body %}
<div class="container mt-4">
<div class="row">
<div class="col-12">
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4 rounded shadow-sm">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="{{ path('app_public_index') }}">{{ 'display.home'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_public_dashboard') }}">{{ 'display.stats'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://openstreetmap.fr/contact/">{{ 'display.contact_humans'|trans }}</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
<div class="row">
<div class="col-12">
@ -50,22 +27,18 @@
<h1 class="card-title mb-4">{{ 'display.welcome'|trans }}</h1>
<div id="map" style="height: 400px; width: 100%;" class="rounded"></div>
{% if commerce is not empty %}
<div class="row mb-4">
<div class="col-12">
</div>
</div>
{% if commerce_overpass is not empty %}
<form action="{{ path('app_public_submit', {'osm_object_id': commerce['@attributes'].id, 'version': commerce['@attributes'].version, 'changesetID': commerce['@attributes'].changeset }) }}" method="post" class="needs-validation">
<form action="{{ path('app_public_submit', {'osm_object_id': commerce_overpass['@attributes'].id, 'version': commerce_overpass['@attributes'].version, 'changesetID': commerce_overpass['@attributes'].changeset }) }}" method="post" class="needs-validation">
<input type="hidden" name="osm_kind" value="{{ osm_kind }}">
<div class="mb-3">
<label for="commerce_id" class="form-label">{{ 'display.modify_commerce'|trans }}:
<strong>
{% if commerce.tags_converted.name is defined %}
{{ commerce.tags_converted.name }}
{% elseif commerce['@attributes'].name is defined %}
{{ commerce['@attributes'].name }}
{% if commerce_overpass.tags_converted.name is defined %}
{{ commerce_overpass.tags_converted.name }}
{% elseif commerce_overpass['@attributes'].name is defined %}
{{ commerce_overpass['@attributes'].name }}
{% else %}
{{ 'display.unknown'|trans }}
{% endif %}
@ -98,7 +71,7 @@
{# cas d'une mairie
#}
{% if commerce.tags_converted.amenity is defined %}
{# {% if commerce.tags_converted.amenity is defined %}
{% if commerce.tags_converted.amenity == 'townhall' %}
{% include 'public/edit/townhall.html.twig' %}
{% endif %}
@ -106,16 +79,16 @@
{% if commerce.tags_converted.amenity == 'restaurant' %}
{% include 'public/edit/restaurant.html.twig' %}
{% endif %}
{% endif %}
{% endif %} #}
</div>
</div>
{% include 'public/edit/ask_angela.html.twig' %}
{% include 'public/edit/wheelchair.html.twig' %}
{% include 'public/edit/opening_hours.html.twig' %}
{% include 'public/edit/address.html.twig' %}
{% include 'public/edit/tags.html.twig' %}
{# {# {% include 'public/edit/wheelchair.html.twig' %} #}
{# {% include 'public/edit/opening_hours.html.twig' %}
{% include 'public/edit/address.html.twig' %} #} #}
{% include 'public/edit/tags.html.twig' %}
<button type="submit" class="btn btn-primary">{{ 'display.submit'|trans }}</button>
</form>
@ -124,15 +97,15 @@
</div>
</div>
<span class="p-3">
<span class="last-modification">{{ 'display.last_modification'|trans }}: {{ commerce['@attributes'].timestamp }}</span>,
{{ 'display.days_ago'|trans({'%days%': date(commerce['@attributes'].timestamp).diff(date()).days}) }}
<span class="last-modification">{{ 'display.last_modification'|trans }}: {{ commerce_overpass['@attributes'].timestamp }}</span>,
{{ 'display.days_ago'|trans({'%days%': date(commerce_overpass['@attributes'].timestamp).diff(date()).days}) }}
{{ 'display.by'|trans }}
<a href="https://www.openstreetmap.org/user/{{ commerce['@attributes'].user }}" target="_blank">{{ commerce['@attributes'].user }}</a>
<a href="https://www.openstreetmap.org/user/{{ commerce_overpass['@attributes'].user }}" target="_blank">{{ commerce_overpass['@attributes'].user }}</a>
<div class="lien-OpenStreetMap">
<a href="https://www.openstreetmap.org/node/{{ commerce['@attributes'].id }}" target="_blank">{{ 'display.view_on_osm'|trans }}</a>
<a href="https://www.openstreetmap.org/node/{{ commerce_overpass['@attributes'].id }}" target="_blank">{{ 'display.view_on_osm'|trans }}</a>
</div>
{{ dump(commerce) }}
{{ dump(commerce_overpass) }}
</span>
<div class="disclaimer p-3">
@ -162,25 +135,31 @@
{% block javascripts %}
{{ parent() }}
<script src='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'></script>
{# <script>
<script>
{% if commerce is not empty and mapbox_token is not empty and maptiler_token is not empty %}
mapboxgl.accessToken = '{{ mapbox_token }}';
map = new mapboxgl.Map({
container: 'map',
style: 'https://api.maptiler.com/maps/basic-v2/style.json?key={{ maptiler_token }}',
center: [{{ commerce['@attributes'].lon }}, {{ commerce['@attributes'].lat }}],
center: [
{# {{ commerce_overpass['@attributes'].lon }}, #}
{# {{ commerce_overpass['@attributes'].lat }} #}
],
zoom: 14
});
// Ajout du marqueur
new mapboxgl.Marker()
.setLngLat([{{ commerce.lon }}, {{ commerce.lat }}])
.setLngLat([
{# {{ commerce.lon }}, #}
{# {{ commerce.lat }} #}
])
.setPopup(new mapboxgl.Popup({
offset: 25
}).setHTML('<h1>{{ commerce.tags_converted.name }}</h1>'))
}).setHTML('<h1>{{ commerce_overpass.tags_converted.name }}</h1>'))
.addTo(map);
{% endif %}
</script> #}
</script>
{% endblock %}
{% endblock %}

View file

@ -2,7 +2,16 @@
<h2>{{ 'display.opening_hours'|trans }}</h2>
<p class="description">{{ 'display.opening_hours_description'|trans }}</p>
<input type="checkbox" name="commerce_tag_value__opening_hours_1" value="yes">
{% if commerce_overpass.tags_converted.opening_hours is defined %}
{{ dump(commerce_overpass.tags_converted.opening_hours) }}
{% else %}
<input type="text" name="commerce_tag_value__opening_hours" value="">
<br> ajoutez les horaires au format OSM
{% endif %}
{# <input type="checkbox" name="commerce_tag_value__opening_hours_1" value="yes">
Lundi de
<input type="number" name="commerce_tag_value__opening_hours_1_midday_hour" value="">
@ -16,7 +25,8 @@
à
<input type="number" name="commerce_tag_value__opening_hours_1_midday_minute" value="">.
<input type="checkbox" name="commerce_tag_value__opening_hours_1_evening" value="yes">
<input type="checkbox" name="commerce_tag_value__opening_hours_1_evening" value="yes"> #}
<hr>
<script src="https://cdn.jsdelivr.net/npm/yohours@0.0.14/src/index.min.js"></script>
</script>
</div>

View file

@ -1,21 +1,16 @@
{% block tags %}<fieldset>
<div id="tags">
{% for attributes in commerce.tag %}
{% for kv in attributes %}
{# {% if kv.k == 'opening_hours' %}
{{ 'display.keys.opening_hours'|trans }}
{% else %} #}
{% for k, v in commerce_overpass.tags_converted %}
<div class="row mb-3">
<div class="col-md-5">
<input type="text" class="form-control hidden" name="commerce_tag_key__{{ kv.k }}" value="{{ kv.k }}" readonly>
<span class="label-translated">{{ ('display.keys.' ~ kv.k)|trans }}</span>
<input type="text" class="form-control hidden" name="commerce_tag_key__{{ k }}" value="{{ k }}" readonly>
<span class="label-translated">{{ ('display.keys.' ~ k)|trans }}</span>
</div>
<div class="col-md-5">
<input type="text" class="form-control" name="commerce_tag_value__{{ kv.k }}" value="{{ kv.v }}">
<input type="text" class="form-control" name="commerce_tag_value__{{ k }}" value="{{ v }}">
</div>
</div>
{# {% endif %} #}
{% endfor %}
{% endfor %}
</div></fieldset>

View file

@ -14,40 +14,12 @@
{% block body %}
<div class="container mt-4">
<div class="row">
<div class="col-12">
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4 rounded shadow-sm">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="{{ path('app_public_index') }}">{{ 'display.home'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_public_dashboard') }}">{{ 'display.stats'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://openstreetmap.fr/contact/">{{ 'display.contact_humans'|trans }}</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<h1 class="card-title mb-4">{{ 'display.welcome'|trans }}</h1>
<div id="map" style="height: 400px; width: 100%;" class="rounded"></div>
</div>
<div class="card shadow-sm p-4">
Ce site permet aux commerçants et aux lieux référencés sur OpenStreetMap de modifier leurs informations facilement pour gagner en visibilité sur des milliers de sites web à la fois.
</div>
</div>
<span class="p-3">