suivi des suppressions d'objets par ville

This commit is contained in:
Tykayn 2025-11-26 00:26:47 +01:00 committed by tykayn
parent 62e086cd64
commit 8e43908cef
13 changed files with 1185 additions and 2 deletions

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 Version20251125223837 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('CREATE TABLE zone_places (id INT AUTO_INCREMENT NOT NULL, disappeared_list JSON DEFAULT NULL, current_list JSON DEFAULT NULL, theme VARCHAR(255) NOT NULL, stats_id INT NOT NULL, INDEX IDX_C1BC442770AA3482 (stats_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8');
$this->addSql('ALTER TABLE zone_places ADD CONSTRAINT FK_C1BC442770AA3482 FOREIGN KEY (stats_id) REFERENCES stats (id)');
$this->addSql('ALTER TABLE place CHANGE email email VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note note LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE name name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE note_content note_content VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE street street VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE housenumber housenumber VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE siret siret VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE osm_user osm_user VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`, CHANGE email_content email_content LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_0900_ai_ci`');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE zone_places DROP FOREIGN KEY FK_C1BC442770AA3482');
$this->addSql('DROP TABLE zone_places');
$this->addSql('ALTER TABLE place CHANGE email email VARCHAR(255) DEFAULT NULL, CHANGE note note LONGTEXT DEFAULT NULL, CHANGE name name VARCHAR(255) DEFAULT NULL, CHANGE note_content note_content VARCHAR(255) DEFAULT NULL, CHANGE street street VARCHAR(255) DEFAULT NULL, CHANGE housenumber housenumber VARCHAR(255) DEFAULT NULL, CHANGE siret siret VARCHAR(255) DEFAULT NULL, CHANGE osm_user osm_user VARCHAR(255) DEFAULT NULL, CHANGE email_content email_content LONGTEXT DEFAULT NULL');
}
}

View file

@ -950,6 +950,82 @@ final class AdminController extends AbstractController
]; ];
} }
// Récupérer les ZonePlaces pour ce thème
$zonePlacesRepository = $this->entityManager->getRepository(\App\Entity\ZonePlaces::class);
$zonePlaces = $zonePlacesRepository->findOneByStatsAndTheme($stats, $theme);
// Si le ZonePlaces n'existe pas, l'initialiser avec les données Overpass du thème courant
if (!$zonePlaces && $theme !== 'places') {
// Récupérer les données Overpass pour ce thème
$elements = $motocultrice->followUpCity($insee_code) ?? [];
// Filtrer les objets selon le thème (même logique que dans FollowUpService)
$themeObjects = $this->filterObjectsByTheme($elements, $theme);
if (!empty($themeObjects)) {
// Créer le ZonePlaces et le remplir
$zonePlaces = new \App\Entity\ZonePlaces();
$zonePlaces->setStats($stats);
$zonePlaces->setTheme($theme);
// Extraire les objets actuels d'Overpass (type, id, user, timestamp, changeset)
$currentObjectsMap = [];
foreach ($themeObjects as $obj) {
if (isset($obj['type']) && isset($obj['id'])) {
$currentObjectsMap[] = [
'type' => $obj['type'],
'id' => $obj['id'],
'user' => $obj['user'] ?? null,
'timestamp' => $obj['timestamp'] ?? null,
'changeset' => $obj['changeset'] ?? null
];
}
}
$zonePlaces->setCurrentList($currentObjectsMap);
$zonePlaces->setDisappearedList([]);
$this->entityManager->persist($zonePlaces);
$this->entityManager->flush();
}
}
// Récupérer les objets supprimés, triés par date de détection (plus récent en premier)
$disappearedObjects = [];
$currentObjectsStats = [
'node' => 0,
'way' => 0,
'relation' => 0,
'total' => 0
];
if ($zonePlaces) {
// Récupérer les objets supprimés
if ($zonePlaces->getDisappearedList()) {
$disappearedObjects = $zonePlaces->getDisappearedList();
// Trier par noticed_deleted_date décroissant (plus récent en premier)
usort($disappearedObjects, function($a, $b) {
$dateA = $a['noticed_deleted_date'] ?? '';
$dateB = $b['noticed_deleted_date'] ?? '';
return strcmp($dateB, $dateA); // Décroissant
});
}
// Compter les objets actuels par type
$currentList = $zonePlaces->getCurrentList() ?? [];
if (is_array($currentList)) {
foreach ($currentList as $obj) {
if (isset($obj['type'])) {
$type = strtolower($obj['type']);
if (in_array($type, ['node', 'way', 'relation'])) {
$currentObjectsStats[$type]++;
$currentObjectsStats['total']++;
}
}
}
}
}
return $this->render('admin/followup_theme_graph.html.twig', [ return $this->render('admin/followup_theme_graph.html.twig', [
'stats' => $stats, 'stats' => $stats,
'theme' => $theme, 'theme' => $theme,
@ -962,6 +1038,9 @@ final class AdminController extends AbstractController
'followup_labels' => $themes, 'followup_labels' => $themes,
'geojson' => json_encode($geojson), 'geojson' => json_encode($geojson),
'overpass_query' => $overpass_query, 'overpass_query' => $overpass_query,
'disappeared_objects' => $disappearedObjects,
'current_objects_stats' => $currentObjectsStats,
'has_zone_places' => $zonePlaces !== null,
'josm_url' => $josm_url, 'josm_url' => $josm_url,
'center' => $center, 'center' => $center,
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null, 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
@ -3860,4 +3939,73 @@ out meta;';
$this->entityManager->persist($stats); $this->entityManager->persist($stats);
} }
} }
/**
* Filtre les objets Overpass selon le thème (même logique que dans FollowUpService)
*/
private function filterObjectsByTheme(array $elements, string $theme): array
{
if ($theme === 'fire_hydrant') {
return array_filter($elements, fn($el) => ($el['tags']['emergency'] ?? null) === 'fire_hydrant') ?? [];
} elseif ($theme === 'charging_station') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'charging_station') ?? [];
} elseif ($theme === 'toilets') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'toilets') ?? [];
} elseif ($theme === 'bus_stop') {
return array_filter($elements, fn($el) => ($el['tags']['highway'] ?? null) === 'bus_stop') ?? [];
} elseif ($theme === 'defibrillator') {
return array_filter($elements, fn($el) => ($el['tags']['emergency'] ?? null) === 'defibrillator') ?? [];
} elseif ($theme === 'camera') {
return array_filter($elements, fn($el) => ($el['tags']['man_made'] ?? null) === 'surveillance') ?? [];
} elseif ($theme === 'recycling') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'recycling') ?? [];
} elseif ($theme === 'substation') {
return array_filter($elements, fn($el) => ($el['tags']['power'] ?? null) === 'substation') ?? [];
} elseif ($theme === 'laboratory') {
return array_filter($elements, fn($el) => ($el['tags']['healthcare'] ?? null) === 'laboratory') ?? [];
} elseif ($theme === 'school') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'school') ?? [];
} elseif ($theme === 'police') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'police') ?? [];
} elseif ($theme === 'healthcare') {
return array_filter($elements, function ($el) {
return isset($el['tags']['healthcare'])
|| ($el['tags']['amenity'] ?? null) === 'doctors'
|| ($el['tags']['amenity'] ?? null) === 'pharmacy'
|| ($el['tags']['amenity'] ?? null) === 'hospital'
|| ($el['tags']['amenity'] ?? null) === 'clinic'
|| ($el['tags']['amenity'] ?? null) === 'social_facility';
}) ?? [];
} elseif ($theme === 'bicycle_parking') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'bicycle_parking') ?? [];
} elseif ($theme === 'advertising_board') {
return array_filter($elements, fn($el) => ($el['tags']['advertising'] ?? null) === 'board' && ($el['tags']['message'] ?? null) === 'political') ?? [];
} elseif ($theme === 'building') {
return array_filter($elements, fn($el) => ($el['type'] ?? null) === 'way' && !empty($el['tags']['building'])) ?? [];
} elseif ($theme === 'email') {
return array_filter($elements, fn($el) => !empty($el['tags']['email'] ?? null) || !empty($el['tags']['contact:email'] ?? null)) ?? [];
} elseif ($theme === 'bench') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'bench') ?? [];
} elseif ($theme === 'waste_basket') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'waste_basket') ?? [];
} elseif ($theme === 'street_lamp') {
return array_filter($elements, fn($el) => ($el['tags']['highway'] ?? null) === 'street_lamp') ?? [];
} elseif ($theme === 'drinking_water') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'drinking_water') ?? [];
} elseif ($theme === 'tree') {
return array_filter($elements, fn($el) => ($el['tags']['natural'] ?? null) === 'tree') ?? [];
} elseif ($theme === 'power_pole') {
return array_filter($elements, fn($el) => ($el['tags']['power'] ?? null) === 'pole') ?? [];
} elseif ($theme === 'manhole') {
return array_filter($elements, fn($el) => ($el['tags']['manhole'] ?? null) === 'manhole') ?? [];
} elseif ($theme === 'little_free_library') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'public_bookcase') ?? [];
} elseif ($theme === 'playground') {
return array_filter($elements, fn($el) => ($el['tags']['leisure'] ?? null) === 'playground') ?? [];
} elseif ($theme === 'restaurant') {
return array_filter($elements, fn($el) => ($el['tags']['amenity'] ?? null) === 'restaurant') ?? [];
}
return [];
}
} }

View file

@ -1361,6 +1361,184 @@ class PublicController extends AbstractController
return $response; return $response;
} }
#[Route('/atom/city/{insee_code}/deletions', name: 'app_public_atom_city_deletions', requirements: ['insee_code' => '\d+'])]
public function atomCityDeletions(string $insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
throw $this->createNotFoundException('Ville non trouvée');
}
// Récupérer tous les ZonePlaces de cette ville
$zonePlaces = $stats->getZonePlaces();
$deletionsByDate = [];
$followupLabels = \App\Service\FollowUpService::getFollowUpThemes();
foreach ($zonePlaces as $zp) {
$theme = $zp->getTheme();
$disappearedList = $zp->getDisappearedList() ?? [];
if (is_array($disappearedList)) {
foreach ($disappearedList as $obj) {
if (isset($obj['noticed_deleted_date'])) {
$date = substr($obj['noticed_deleted_date'], 0, 10); // YYYY-MM-DD
if (!isset($deletionsByDate[$date])) {
$deletionsByDate[$date] = [];
}
if (!isset($deletionsByDate[$date][$theme])) {
$deletionsByDate[$date][$theme] = [];
}
$deletionsByDate[$date][$theme][] = $obj;
}
}
}
}
// Trier par date décroissante
krsort($deletionsByDate);
$content = $this->renderView('public/atom/city_deletions.xml.twig', [
'stats' => $stats,
'deletionsByDate' => $deletionsByDate,
'followup_labels' => $followupLabels,
'base_url' => $this->getParameter('router.request_context.host'),
]);
$response = new Response($content);
$response->headers->set('Content-Type', 'application/atom+xml');
return $response;
}
#[Route('/atom/city/{insee_code}/creations', name: 'app_public_atom_city_creations', requirements: ['insee_code' => '\d+'])]
public function atomCityCreations(string $insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
throw $this->createNotFoundException('Ville non trouvée');
}
// Récupérer tous les ZonePlaces de cette ville
$zonePlaces = $stats->getZonePlaces();
$creationsByDate = [];
$followupLabels = \App\Service\FollowUpService::getFollowUpThemes();
$thirtyDaysAgo = new \DateTime('-30 days');
foreach ($zonePlaces as $zp) {
$theme = $zp->getTheme();
$currentList = $zp->getCurrentList() ?? [];
if (is_array($currentList)) {
foreach ($currentList as $obj) {
if (isset($obj['timestamp'])) {
try {
$timestamp = new \DateTime($obj['timestamp']);
// Ne garder que les objets créés/modifiés récemment (30 derniers jours)
if ($timestamp >= $thirtyDaysAgo) {
$date = $timestamp->format('Y-m-d');
if (!isset($creationsByDate[$date])) {
$creationsByDate[$date] = [];
}
if (!isset($creationsByDate[$date][$theme])) {
$creationsByDate[$date][$theme] = [];
}
$creationsByDate[$date][$theme][] = $obj;
}
} catch (\Exception $e) {
// Ignorer les timestamps invalides
}
}
}
}
}
// Trier par date décroissante
krsort($creationsByDate);
$content = $this->renderView('public/atom/city_creations.xml.twig', [
'stats' => $stats,
'creationsByDate' => $creationsByDate,
'followup_labels' => $followupLabels,
'base_url' => $this->getParameter('router.request_context.host'),
]);
$response = new Response($content);
$response->headers->set('Content-Type', 'application/atom+xml');
return $response;
}
#[Route('/city/{insee_code}/zone-places-history', name: 'app_public_zone_places_history', requirements: ['insee_code' => '\d+'])]
public function zonePlacesHistory(string $insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
throw $this->createNotFoundException('Ville non trouvée');
}
// Récupérer tous les ZonePlaces de cette ville
$zonePlaces = $stats->getZonePlaces();
$changesByDate = [];
$followupLabels = \App\Service\FollowUpService::getFollowUpThemes();
$followupIcons = \App\Service\FollowUpService::getFollowUpIcons();
$thirtyDaysAgo = new \DateTime('-30 days');
foreach ($zonePlaces as $zp) {
$theme = $zp->getTheme();
// Traiter les suppressions
$disappearedList = $zp->getDisappearedList() ?? [];
if (is_array($disappearedList)) {
foreach ($disappearedList as $obj) {
if (isset($obj['noticed_deleted_date'])) {
$date = substr($obj['noticed_deleted_date'], 0, 10);
if (!isset($changesByDate[$date])) {
$changesByDate[$date] = ['deletions' => [], 'creations' => []];
}
if (!isset($changesByDate[$date]['deletions'][$theme])) {
$changesByDate[$date]['deletions'][$theme] = [];
}
$changesByDate[$date]['deletions'][$theme][] = $obj;
}
}
}
// Traiter les créations/modifications récentes
$currentList = $zp->getCurrentList() ?? [];
if (is_array($currentList)) {
foreach ($currentList as $obj) {
if (isset($obj['timestamp'])) {
try {
$timestamp = new \DateTime($obj['timestamp']);
if ($timestamp >= $thirtyDaysAgo) {
$date = $timestamp->format('Y-m-d');
if (!isset($changesByDate[$date])) {
$changesByDate[$date] = ['deletions' => [], 'creations' => []];
}
if (!isset($changesByDate[$date]['creations'][$theme])) {
$changesByDate[$date]['creations'][$theme] = [];
}
$changesByDate[$date]['creations'][$theme][] = $obj;
}
} catch (\Exception $e) {
// Ignorer les timestamps invalides
}
}
}
}
}
// Trier par date décroissante
krsort($changesByDate);
return $this->render('public/zone_places_history.html.twig', [
'stats' => $stats,
'changesByDate' => $changesByDate,
'followup_labels' => $followupLabels,
'followup_icons' => $followupIcons,
]);
}
#[Route('/city/{insee_code}/demandes', name: 'app_public_city_demandes')] #[Route('/city/{insee_code}/demandes', name: 'app_public_city_demandes')]
public function cityDemandes(string $insee_code): Response public function cityDemandes(string $insee_code): Response
{ {

View file

@ -127,6 +127,13 @@ class Stats
#[ORM\Column(length: 20, nullable: true)] #[ORM\Column(length: 20, nullable: true)]
private ?string $kind = 'command'; private ?string $kind = 'command';
/**
* @var Collection<int, ZonePlaces>
*/
#[ORM\OneToMany(targetEntity: ZonePlaces::class, mappedBy: 'stats', orphanRemoval: true)]
private Collection $ZonePlaces;
public function getCTCurlBase(): ?string public function getCTCurlBase(): ?string
{ {
$base = 'https://complete-tes-commerces.fr/'; $base = 'https://complete-tes-commerces.fr/';
@ -277,6 +284,7 @@ class Stats
$this->places = new ArrayCollection(); $this->places = new ArrayCollection();
$this->statsHistories = new ArrayCollection(); $this->statsHistories = new ArrayCollection();
$this->cityFollowUps = new ArrayCollection(); $this->cityFollowUps = new ArrayCollection();
$this->ZonePlaces = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@ -676,4 +684,35 @@ class Stats
$this->kind = $kind; $this->kind = $kind;
return $this; return $this;
} }
/**
* @return Collection<int, ZonePlaces>
*/
public function getZonePlaces(): Collection
{
return $this->ZonePlaces;
}
public function addZonePlace(ZonePlaces $zonePlace): static
{
if (!$this->ZonePlaces->contains($zonePlace)) {
$this->ZonePlaces->add($zonePlace);
$zonePlace->setStats($this);
}
return $this;
}
public function removeZonePlace(ZonePlaces $zonePlace): static
{
if ($this->ZonePlaces->removeElement($zonePlace)) {
// set the owning side to null (unless already changed)
if ($zonePlace->getStats() === $this) {
$zonePlace->setStats(null);
}
}
return $this;
}
} }

98
src/Entity/ZonePlaces.php Normal file
View file

@ -0,0 +1,98 @@
<?php
namespace App\Entity;
use App\Repository\ZonePlacesRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ZonePlacesRepository::class)]
class ZonePlaces
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* liste d'objets disparus dans OSM.
*
* @var mixed
*/
#[ORM\Column(type: Types::JSONB, nullable: true)]
private mixed $disappearedList = null;
/**
* liste d'objets courants dans OSM.
*
* @var mixed
*/
#[ORM\Column(type: Types::JSONB, nullable: true)]
private mixed $currentList = null;
/**
* objet Stats, une ville reliée à une liste d'objets courants et une liste d'objets disparus dans OSM.
*
* @var Stats|null
*/
#[ORM\ManyToOne(inversedBy: 'ZonePlaces')]
#[ORM\JoinColumn(nullable: false)]
private ?Stats $stats = null;
#[ORM\Column(length: 255)]
private ?string $theme = null;
public function getId(): ?int
{
return $this->id;
}
public function getStats(): ?Stats
{
return $this->stats;
}
public function setStats(?Stats $stats): static
{
$this->stats = $stats;
return $this;
}
public function getDisappearedList(): mixed
{
return $this->disappearedList;
}
public function setDisappearedList(mixed $disappearedList): static
{
$this->disappearedList = $disappearedList;
return $this;
}
public function getCurrentList(): mixed
{
return $this->currentList;
}
public function setCurrentList(mixed $currentList): static
{
$this->currentList = $currentList;
return $this;
}
public function getTheme(): ?string
{
return $this->theme;
}
public function setTheme(string $theme): static
{
$this->theme = $theme;
return $this;
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Repository;
use App\Entity\ZonePlaces;
use App\Entity\Stats;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ZonePlaces>
*/
class ZonePlacesRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ZonePlaces::class);
}
/**
* Trouve un ZonePlaces pour un Stats et un thème donnés
*/
public function findOneByStatsAndTheme(Stats $stats, string $theme): ?ZonePlaces
{
return $this->createQueryBuilder('z')
->andWhere('z.stats = :stats')
->andWhere('z.theme = :theme')
->setParameter('stats', $stats)
->setParameter('theme', $theme)
->getQuery()
->getOneOrNullResult();
}
// /**
// * @return ZonePlaces[] Returns an array of ZonePlaces objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('z')
// ->andWhere('z.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('z.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ZonePlaces
// {
// return $this->createQueryBuilder('z')
// ->andWhere('z.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View file

@ -4,6 +4,8 @@ namespace App\Service;
use App\Entity\CityFollowUp; use App\Entity\CityFollowUp;
use App\Entity\Stats; use App\Entity\Stats;
use App\Entity\ZonePlaces;
use App\Repository\ZonePlacesRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
class FollowUpService class FollowUpService
@ -86,6 +88,17 @@ class FollowUpService
$now = new \DateTime(); $now = new \DateTime();
$persisted = 0; $persisted = 0;
// Gestion des ZonePlaces pour chaque thème (sauf 'places')
$zonePlacesRepository = $em->getRepository(ZonePlaces::class);
foreach ($types as $type => $data) {
// On ne gère pas les ZonePlaces pour le thème 'places'
if ($type !== 'places' && !empty($data['objects'])) {
$this->updateZonePlacesForTheme($stats, $type, $data['objects'], $zonePlacesRepository, $em, $now);
}
}
// Flush des ZonePlaces avant de continuer avec les CityFollowUp
$em->flush();
foreach ($types as $type => $data) { foreach ($types as $type => $data) {
// Suivi du nombre // Suivi du nombre
$measureCount = $type === 'places' ? $stats->getPlacesCount() : count($data['objects']); $measureCount = $type === 'places' ? $stats->getPlacesCount() : count($data['objects']);
@ -517,4 +530,102 @@ class FollowUpService
'restaurant' => ['contact:phone', 'phone', 'contact:email', 'email', 'contact:website', 'website', 'cuisine', 'ref:FR:SIRET'], 'restaurant' => ['contact:phone', 'phone', 'contact:email', 'email', 'contact:website', 'website', 'cuisine', 'ref:FR:SIRET'],
]; ];
} }
/**
* Met à jour ou crée un ZonePlaces pour un thème donné
* Compare les objets actuels avec ceux stockés pour détecter les disparus
*/
private function updateZonePlacesForTheme(
Stats $stats,
string $theme,
array $currentObjects,
ZonePlacesRepository $repository,
EntityManagerInterface $em,
\DateTime $now
): void {
// Récupérer ou créer le ZonePlaces pour ce thème
$zonePlaces = $repository->findOneByStatsAndTheme($stats, $theme);
if (!$zonePlaces) {
// Créer un nouveau ZonePlaces
$zonePlaces = new ZonePlaces();
$zonePlaces->setStats($stats);
$zonePlaces->setTheme($theme);
$zonePlaces->setCurrentList([]);
$zonePlaces->setDisappearedList([]);
}
// Extraire les objets actuels d'Overpass (type, id, user, timestamp, changeset)
$currentObjectsMap = [];
foreach ($currentObjects as $obj) {
if (isset($obj['type']) && isset($obj['id'])) {
$key = $obj['type'] . '_' . $obj['id'];
$currentObjectsMap[$key] = [
'type' => $obj['type'],
'id' => $obj['id'],
'user' => $obj['user'] ?? null,
'timestamp' => $obj['timestamp'] ?? null,
'changeset' => $obj['changeset'] ?? null
];
}
}
// Récupérer les objets précédemment stockés
$previousCurrentList = $zonePlaces->getCurrentList() ?? [];
$previousDisappearedList = $zonePlaces->getDisappearedList() ?? [];
// Convertir previousCurrentList en map pour faciliter la comparaison
$previousCurrentMap = [];
if (is_array($previousCurrentList)) {
foreach ($previousCurrentList as $obj) {
if (isset($obj['type']) && isset($obj['id'])) {
$key = $obj['type'] . '_' . $obj['id'];
$previousCurrentMap[$key] = $obj;
}
}
}
// Détecter les objets disparus (présents dans previousCurrentMap mais pas dans currentObjectsMap)
$disappearedObjects = [];
foreach ($previousCurrentMap as $key => $obj) {
if (!isset($currentObjectsMap[$key])) {
// L'objet a disparu, on l'ajoute à la liste des disparus avec la date de détection
// On conserve seulement les infos essentielles : type, id, user, timestamp, changeset
$disappearedObj = [
'type' => $obj['type'] ?? null,
'id' => $obj['id'] ?? null,
'user' => $obj['user'] ?? null,
'timestamp' => $obj['timestamp'] ?? null,
'changeset' => $obj['changeset'] ?? null,
'noticed_deleted_date' => $now->format('Y-m-d H:i:s')
];
$disappearedObjects[] = $disappearedObj;
}
}
// Mettre à jour la liste des disparus (ajouter les nouveaux disparus)
$updatedDisappearedList = is_array($previousDisappearedList) ? $previousDisappearedList : [];
foreach ($disappearedObjects as $disappearedObj) {
// Vérifier si l'objet n'est pas déjà dans la liste des disparus
$key = $disappearedObj['type'] . '_' . $disappearedObj['id'];
$alreadyDisappeared = false;
foreach ($updatedDisappearedList as $existing) {
if (isset($existing['type']) && isset($existing['id'])
&& $existing['type'] === $disappearedObj['type']
&& $existing['id'] === $disappearedObj['id']) {
$alreadyDisappeared = true;
break;
}
}
if (!$alreadyDisappeared) {
$updatedDisappearedList[] = $disappearedObj;
}
}
// Mettre à jour currentList avec les objets actuels
$zonePlaces->setCurrentList(array_values($currentObjectsMap));
$zonePlaces->setDisappearedList($updatedDisappearedList);
$em->persist($zonePlaces);
}
} }

View file

@ -60,6 +60,9 @@
<a class="nav-link {% if active_menu == 'stats_evolutions' %}active{% endif %}" href="{{ path('app_public_stats_evolutions', {'insee_code': stats.zone}) }}"> <a class="nav-link {% if active_menu == 'stats_evolutions' %}active{% endif %}" href="{{ path('app_public_stats_evolutions', {'insee_code': stats.zone}) }}">
<i class="bi bi-activity"></i> Évolutions des objets <i class="bi bi-activity"></i> Évolutions des objets
</a> </a>
<a class="nav-link {% if active_menu == 'zone_places_history' %}active{% endif %}" href="{{ path('app_public_zone_places_history', {'insee_code': stats.zone}) }}">
<i class="bi bi-clock-history"></i> Historique ZonePlaces
</a>
<a class="nav-link {% if active_menu == 'street_completion' %}active{% endif %}" href="{{ path('admin_street_completion', {'insee_code': stats.zone}) }}"> <a class="nav-link {% if active_menu == 'street_completion' %}active{% endif %}" href="{{ path('admin_street_completion', {'insee_code': stats.zone}) }}">
<i class="bi bi-signpost"></i> Complétion des rues <i class="bi bi-signpost"></i> Complétion des rues
</a> </a>
@ -74,8 +77,8 @@
{# </a>#} {# </a>#}
</nav> </nav>
<!-- Flux RSS --> <!-- Flux RSS/Atom -->
<div class="sidebar-heading">Flux RSS</div> <div class="sidebar-heading">Flux RSS/Atom</div>
<nav class="nav flex-column"> <nav class="nav flex-column">
{# <a class="nav-link" href="{{ path('app_public_rss_city_demandes', {'insee_code': stats.zone}) }}" target="_blank">#} {# <a class="nav-link" href="{{ path('app_public_rss_city_demandes', {'insee_code': stats.zone}) }}" target="_blank">#}
{# <i class="bi bi-rss"></i> Demandes#} {# <i class="bi bi-rss"></i> Demandes#}
@ -83,6 +86,12 @@
{# <a class="nav-link" href="{{ path('app_public_rss_city_themes', {'insee_code': stats.zone}) }}" target="_blank">#} {# <a class="nav-link" href="{{ path('app_public_rss_city_themes', {'insee_code': stats.zone}) }}" target="_blank">#}
{# <i class="bi bi-rss"></i> Changements thématiques#} {# <i class="bi bi-rss"></i> Changements thématiques#}
{# </a>#} {# </a>#}
<a class="nav-link" href="{{ path('app_public_atom_city_deletions', {'insee_code': stats.zone}) }}" target="_blank">
<i class="bi bi-trash"></i> Suppressions (Atom)
</a>
<a class="nav-link" href="{{ path('app_public_atom_city_creations', {'insee_code': stats.zone}) }}" target="_blank">
<i class="bi bi-plus-circle"></i> Créations (Atom)
</a>
</nav> </nav>
<!-- Actions --> <!-- Actions -->

View file

@ -8,6 +8,12 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<style>
table {
max-height: 600px;
overflow-y: auto;
}
</style>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<!-- Sidebar de navigation --> <!-- Sidebar de navigation -->

View file

@ -281,6 +281,152 @@
{# </div> #} {# </div> #}
</div> </div>
{# Section des objets supprimés et statistiques #}
{% if has_zone_places is defined and has_zone_places %}
<div class="card mt-4 mb-4">
<div class="card-header">
<h4><i class="bi bi-info-circle"></i> Suivi des objets OSM</h4>
</div>
<div class="card-body">
{# Statistiques des objets actuels #}
{% if current_objects_stats is defined %}
<div class="mb-4">
<h5><i class="bi bi-list-ul"></i> Objets actuels suivis</h5>
<div class="row">
<div class="col-md-3">
<div class="card bg-light">
<div class="card-body text-center">
<div class="h3 mb-0">{{ current_objects_stats.node|default(0) }}</div>
<div class="text-muted">Nodes</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-light">
<div class="card-body text-center">
<div class="h3 mb-0">{{ current_objects_stats.way|default(0) }}</div>
<div class="text-muted">Ways</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-light">
<div class="card-body text-center">
<div class="h3 mb-0">{{ current_objects_stats.relation|default(0) }}</div>
<div class="text-muted">Relations</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body text-center">
<div class="h3 mb-0">{{ current_objects_stats.total|default(0) }}</div>
<div>Total</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{# Section des objets supprimés #}
<div class="mt-4">
<h5><i class="bi bi-trash"></i> Objets supprimés récemment</h5>
{% if disappeared_objects is defined and disappeared_objects|length > 0 %}
<p class="text-muted">Liste des objets OSM qui ont été supprimés, triés du plus récent au plus ancien.</p>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Type</th>
<th>ID</th>
<th>Créateur</th>
<th>Dernière modification</th>
<th>Changeset</th>
<th>Date de suppression détectée</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for obj in disappeared_objects %}
<tr>
<td><span class="badge bg-secondary">{{ obj.type|upper }}</span></td>
<td><code>{{ obj.id }}</code></td>
<td>{{ obj.user|default('Inconnu') }}</td>
<td>
{% if obj.timestamp %}
{% if obj.timestamp matches '/^\\d{4}-\\d{2}-\\d{2}/' %}
{{ obj.timestamp }}
{% else %}
{{ obj.timestamp|date('Y-m-d H:i:s') }}
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if obj.changeset %}
<a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}"
target="_blank"
title="Voir le changeset dans achavi"
class="text-decoration-none">
<code>{{ obj.changeset }}</code>
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if obj.noticed_deleted_date %}
<span class="text-danger">{{ obj.noticed_deleted_date }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% set josm_type = obj.type == 'node' ? 'n' : (obj.type == 'way' ? 'w' : 'r') %}
<a href="http://127.0.0.1:8111/load_object?objects={{ josm_type }}{{ obj.id }}"
class="btn btn-primary"
title="Ouvrir dans JOSM"
target="_blank">
<i class="bi bi-tools"></i> JOSM
</a>
<a href="https://osmlab.github.io/osm-deep-history/#/{{ obj.type }}/{{ obj.id }}"
class="btn btn-info"
title="Voir l'historique dans OSM Deep History"
target="_blank">
<i class="bi bi-clock-history"></i> Historique
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<i class="bi bi-check-circle"></i> Aucune suppression d'objet n'a été détectée pour ce thème.
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="card mt-4 mb-4">
<div class="card-header">
<h4><i class="bi bi-info-circle"></i> Suivi des objets OSM</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> Le suivi des objets OSM n'a pas encore été initialisé pour ce thème.
Il sera créé lors de la prochaine mise à jour du followup.
</div>
</div>
</div>
{% endif %}
<div class="card mt-4 mb-4"> <div class="card mt-4 mb-4">
<div class="card-header"> <div class="card-header">

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Créations d'objets OSM - {{ stats.name }}</title>
<link href="https://{{ base_url }}{{ path('app_public_atom_city_creations', {'insee_code': stats.zone}) }}" rel="self" />
<link href="https://{{ base_url }}{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" />
<id>https://{{ base_url }}/atom/city/{{ stats.zone }}/creations</id>
<updated>{{ "now"|date("Y-m-d\\TH:i:s\\Z") }}</updated>
<author>
<name>OSM Commerces</name>
</author>
<subtitle>Flux Atom des créations/modifications récentes d'objets OSM pour {{ stats.name }} ({{ stats.zone }})</subtitle>
{% for date, themes in creationsByDate %}
{% for theme, objects in themes %}
{% set themeLabel = followup_labels[theme]|default(theme|capitalize) %}
{% for obj in objects %}
<entry>
<title>Création/Modification : {{ themeLabel }} - {{ obj.type|upper }} {{ obj.id }}</title>
<link href="https://www.openstreetmap.org/{{ obj.type }}/{{ obj.id }}" />
<id>https://{{ base_url }}/atom/city/{{ stats.zone }}/creations/{{ date }}/{{ theme }}/{{ obj.type }}/{{ obj.id }}</id>
<updated>{% if obj.timestamp %}{{ obj.timestamp|replace({' ': 'T'}) }}Z{% else %}{{ "now"|date("Y-m-d\\TH:i:s\\Z") }}{% endif %}</updated>
<summary type="html">
<![CDATA[
<p><strong>Thème:</strong> {{ themeLabel }}</p>
<p><strong>Type:</strong> {{ obj.type|upper }}</p>
<p><strong>ID OSM:</strong> {{ obj.id }}</p>
{% if obj.user %}
<p><strong>Contributeur:</strong> {{ obj.user }}</p>
{% endif %}
{% if obj.timestamp %}
<p><strong>Date de modification:</strong> {{ obj.timestamp }}</p>
{% endif %}
{% if obj.changeset %}
<p><strong>Changeset:</strong> <a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}">{{ obj.changeset }}</a></p>
{% endif %}
]]>
</summary>
<content type="html">
<![CDATA[
<p><strong>Thème:</strong> {{ themeLabel }}</p>
<p><strong>Type:</strong> {{ obj.type|upper }}</p>
<p><strong>ID OSM:</strong> <a href="https://www.openstreetmap.org/{{ obj.type }}/{{ obj.id }}">{{ obj.id }}</a></p>
{% if obj.user %}
<p><strong>Contributeur:</strong> <a href="https://www.openstreetmap.org/user/{{ obj.user|url_encode }}">{{ obj.user }}</a></p>
{% endif %}
{% if obj.timestamp %}
<p><strong>Date de modification:</strong> {{ obj.timestamp }}</p>
{% endif %}
{% if obj.changeset %}
<p><strong>Changeset:</strong> <a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}">{{ obj.changeset }}</a></p>
{% endif %}
<p>
<a href="https://osmlab.github.io/osm-deep-history/#/{{ obj.type }}/{{ obj.id }}">Historique OSM Deep History</a> |
{% if obj.changeset %}
<a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}">Voir dans achavi</a> |
{% endif %}
<a href="https://www.openstreetmap.org/{{ obj.type }}/{{ obj.id }}">Voir sur OSM</a>
</p>
]]>
</content>
</entry>
{% endfor %}
{% endfor %}
{% endfor %}
</feed>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Suppressions d'objets OSM - {{ stats.name }}</title>
<link href="https://{{ base_url }}{{ path('app_public_atom_city_deletions', {'insee_code': stats.zone}) }}" rel="self" />
<link href="https://{{ base_url }}{{ path('app_admin_stats', {'insee_code': stats.zone}) }}" />
<id>https://{{ base_url }}/atom/city/{{ stats.zone }}/deletions</id>
<updated>{{ "now"|date("Y-m-d\\TH:i:s\\Z") }}</updated>
<author>
<name>OSM Commerces</name>
</author>
<subtitle>Flux Atom des suppressions d'objets OSM pour {{ stats.name }} ({{ stats.zone }})</subtitle>
{% for date, themes in deletionsByDate %}
{% for theme, objects in themes %}
{% set themeLabel = followup_labels[theme]|default(theme|capitalize) %}
{% for obj in objects %}
<entry>
<title>Suppression : {{ themeLabel }} - {{ obj.type|upper }} {{ obj.id }}</title>
<link href="https://osmlab.github.io/osm-deep-history/#/{{ obj.type }}/{{ obj.id }}" />
<id>https://{{ base_url }}/atom/city/{{ stats.zone }}/deletions/{{ date }}/{{ theme }}/{{ obj.type }}/{{ obj.id }}</id>
<updated>{% if obj.noticed_deleted_date %}{{ obj.noticed_deleted_date|replace({' ': 'T', '/': '-'}) }}Z{% else %}{{ "now"|date("Y-m-d\\TH:i:s\\Z") }}{% endif %}</updated>
<summary type="html">
<![CDATA[
<p><strong>Thème:</strong> {{ themeLabel }}</p>
<p><strong>Type:</strong> {{ obj.type|upper }}</p>
<p><strong>ID OSM:</strong> {{ obj.id }}</p>
{% if obj.user %}
<p><strong>Dernier contributeur:</strong> {{ obj.user }}</p>
{% endif %}
{% if obj.timestamp %}
<p><strong>Dernière modification:</strong> {{ obj.timestamp }}</p>
{% endif %}
{% if obj.changeset %}
<p><strong>Changeset:</strong> <a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}">{{ obj.changeset }}</a></p>
{% endif %}
<p><strong>Date de suppression détectée:</strong> {{ obj.noticed_deleted_date }}</p>
]]>
</summary>
<content type="html">
<![CDATA[
<p><strong>Thème:</strong> {{ themeLabel }}</p>
<p><strong>Type:</strong> {{ obj.type|upper }}</p>
<p><strong>ID OSM:</strong> <a href="https://www.openstreetmap.org/{{ obj.type }}/{{ obj.id }}">{{ obj.id }}</a></p>
{% if obj.user %}
<p><strong>Dernier contributeur:</strong> <a href="https://www.openstreetmap.org/user/{{ obj.user|url_encode }}">{{ obj.user }}</a></p>
{% endif %}
{% if obj.timestamp %}
<p><strong>Dernière modification:</strong> {{ obj.timestamp }}</p>
{% endif %}
{% if obj.changeset %}
<p><strong>Changeset:</strong> <a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}">{{ obj.changeset }}</a></p>
{% endif %}
<p><strong>Date de suppression détectée:</strong> {{ obj.noticed_deleted_date }}</p>
<p>
<a href="https://osmlab.github.io/osm-deep-history/#/{{ obj.type }}/{{ obj.id }}">Historique OSM Deep History</a> |
{% if obj.changeset %}
<a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}">Voir dans achavi</a> |
{% endif %}
<a href="https://www.openstreetmap.org/{{ obj.type }}/{{ obj.id }}">Voir sur OSM</a>
</p>
]]>
</content>
</entry>
{% endfor %}
{% endfor %}
{% endfor %}
</feed>

View file

@ -0,0 +1,221 @@
{% extends 'base.html.twig' %}
{% block title %}Historique ZonePlaces - {{ stats.name }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link href='{{ asset('css/city-sidebar.css') }}' rel='stylesheet'/>
{% endblock %}
{% block body %}
<div class="container-fluid">
<div class="row">
<!-- Sidebar de navigation -->
<div class="col-12">
{% include 'admin/_city_sidebar.html.twig' with {'stats': stats, 'active_menu': 'zone_places_history'} %}
</div>
<!-- Contenu principal -->
<div class="col-md-9 col-lg-10 main-content">
<div class="p-4">
<h1>Historique ZonePlaces - {{ stats.name }} ({{ stats.zone }})</h1>
<p class="text-muted">Historique combiné des suppressions et créations d'objets OSM par thème, groupé par date.</p>
{% if changesByDate is empty %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Aucun changement enregistré pour cette ville.
</div>
{% else %}
{% for date, changes in changesByDate %}
<div class="card mb-4">
<div class="card-header">
<h3><i class="bi bi-calendar"></i> {{ date|date('d/m/Y') }}</h3>
</div>
<div class="card-body">
{# Suppressions #}
{% if changes.deletions is not empty %}
<div class="mb-4">
<h4 class="text-danger"><i class="bi bi-trash"></i> Suppressions</h4>
{% for theme, objects in changes.deletions %}
{% set themeLabel = followup_labels[theme]|default(theme|capitalize) %}
{% set themeIcon = followup_icons[theme]|default('bi-question-circle') %}
<div class="mb-3">
<h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} suppression{{ objects|length > 1 ? 's' : '' }})</h5>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Thème</th>
<th>Type</th>
<th>ID</th>
<th>Contributeur</th>
<th>Dernière modification</th>
<th>Changeset</th>
<th>Date suppression</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for obj in objects %}
<tr>
<td><span class="badge bg-info">{{ themeLabel }}</span></td>
<td><span class="badge bg-secondary">{{ obj.type|upper }}</span></td>
<td><code>{{ obj.id }}</code></td>
<td>
{% if obj.user %}
<a href="https://www.openstreetmap.org/user/{{ obj.user|url_encode }}" target="_blank">
{{ obj.user }}
<i class="bi bi-box-arrow-up-right"></i>
</a>
{% else %}
<span class="text-muted">Inconnu</span>
{% endif %}
</td>
<td>
{% if obj.timestamp %}
{{ obj.timestamp }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if obj.changeset %}
<a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}" target="_blank">
<code>{{ obj.changeset }}</code>
<i class="bi bi-box-arrow-up-right"></i>
</a>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td><span class="text-danger">{{ obj.noticed_deleted_date }}</span></td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% set josm_type = obj.type == 'node' ? 'n' : (obj.type == 'way' ? 'w' : 'r') %}
<a href="http://127.0.0.1:8111/load_object?objects={{ josm_type }}{{ obj.id }}"
class="btn btn-primary btn-sm"
title="Ouvrir dans JOSM"
target="_blank">
<i class="bi bi-tools"></i> JOSM
</a>
<a href="https://osmlab.github.io/osm-deep-history/#/{{ obj.type }}/{{ obj.id }}"
class="btn btn-info btn-sm"
title="Voir l'historique dans OSM Deep History"
target="_blank">
<i class="bi bi-clock-history"></i> Historique
</a>
<a href="https://www.openstreetmap.org/{{ obj.type }}/{{ obj.id }}"
class="btn btn-secondary btn-sm"
title="Voir sur OSM"
target="_blank">
<i class="bi bi-geo-alt"></i> OSM
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# Créations #}
{% if changes.creations is not empty %}
<div>
<h4 class="text-success"><i class="bi bi-plus-circle"></i> Créations/Modifications</h4>
{% for theme, objects in changes.creations %}
{% set themeLabel = followup_labels[theme]|default(theme|capitalize) %}
{% set themeIcon = followup_icons[theme]|default('bi-question-circle') %}
<div class="mb-3">
<h5><i class="bi {{ themeIcon }}"></i> {{ themeLabel }} ({{ objects|length }} objet{{ objects|length > 1 ? 's' : '' }})</h5>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Thème</th>
<th>Type</th>
<th>ID</th>
<th>Contributeur</th>
<th>Date modification</th>
<th>Changeset</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for obj in objects %}
<tr>
<td><span class="badge bg-info">{{ theme }}</span></td>
<td><span class="badge bg-success">{{ obj.type|upper }}</span></td>
<td><code>{{ obj.id }}</code></td>
<td>
{% if obj.user %}
<a href="https://www.openstreetmap.org/user/{{ obj.user|url_encode }}" target="_blank">
{{ obj.user }}
<i class="bi bi-box-arrow-up-right"></i>
</a>
{% else %}
<span class="text-muted">Inconnu</span>
{% endif %}
</td>
<td>
{% if obj.timestamp %}
{{ obj.timestamp }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
{% if obj.changeset %}
<a href="https://overpass-api.de/achavi/?changeset={{ obj.changeset }}" target="_blank">
<code>{{ obj.changeset }}</code>
<i class="bi bi-box-arrow-up-right"></i>
</a>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% set josm_type = obj.type == 'node' ? 'n' : (obj.type == 'way' ? 'w' : 'r') %}
<a href="http://127.0.0.1:8111/load_object?objects={{ josm_type }}{{ obj.id }}"
class="btn btn-primary btn-sm"
title="Ouvrir dans JOSM"
target="_blank">
<i class="bi bi-tools"></i> JOSM
</a>
<a href="https://osmlab.github.io/osm-deep-history/#/{{ obj.type }}/{{ obj.id }}"
class="btn btn-info btn-sm"
title="Voir l'historique dans OSM Deep History"
target="_blank">
<i class="bi bi-clock-history"></i> Historique
</a>
<a href="https://www.openstreetmap.org/{{ obj.type }}/{{ obj.id }}"
class="btn btn-secondary btn-sm"
title="Voir sur OSM"
target="_blank">
<i class="bi bi-geo-alt"></i> OSM
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}