Browse Source

commente un peu plus le projet

master
vincent 3 months ago
parent
commit
6833889cf2
26 changed files with 280 additions and 129 deletions
  1. +58
    -8
      README.md
  2. +6
    -4
      assets/app.js
  3. +7
    -0
      assets/controllers/clipboard_controller.js
  4. +37
    -1
      assets/controllers/josm_controller.js
  5. +45
    -11
      assets/controllers/map_controller.js
  6. +1
    -0
      assets/styles/app.css
  7. +8
    -2
      src/Controller/HomeController.php
  8. +3
    -0
      src/Controller/MapController.php
  9. +16
    -27
      src/Controller/ProjectController.php
  10. +39
    -73
      src/Controller/TaskController.php
  11. +2
    -1
      src/DataFixtures/AppFixtures.php
  12. +3
    -0
      src/Entity/Comment.php
  13. +2
    -0
      src/Entity/Project.php
  14. +2
    -0
      src/Entity/Tag.php
  15. +2
    -0
      src/Entity/Task.php
  16. +2
    -0
      src/Entity/User.php
  17. +14
    -0
      src/EventSubscriber/TaskLifecycleSubscriber.php
  18. +1
    -1
      src/Form/TaskType.php
  19. +2
    -0
      src/Service/GeoJsonManager.php
  20. +1
    -0
      src/Service/OpenStreetMapClient.php
  21. +3
    -0
      src/Service/OsmoseClient.php
  22. +1
    -0
      src/Service/OverpassClient.php
  23. +1
    -0
      src/Service/SourceGenerator.php
  24. +1
    -0
      src/Service/TaskLifecycleManager.php
  25. +1
    -1
      templates/_header.html.twig
  26. +22
    -0
      templates/macro.html.twig

+ 58
- 8
README.md View File

@ -1,4 +1,13 @@
L'idée serait de faire une application Symfony pour gérer simplement des tâches de cartographie.
# Gestionnaire de tâches simple
Voici un outil pour faciliter le travail collaboratif autour d’un projet. Le
projet regroupe des tâches que les contributeurs OSM peuvent s’approprier et
traiter dans JOSM.
# Idée de départ
L'idée serait de faire une application Symfony pour gérer simplement des tâches
de cartographie.
* authentification OAuth2 sur OSM pour ne pas avoir à créer de compte * authentification OAuth2 sur OSM pour ne pas avoir à créer de compte
* télécommande JOSM * télécommande JOSM
@ -6,19 +15,60 @@ L'idée serait de faire une application Symfony pour gérer simplement des tâch
* carte maplibre ou leaflet synchronisée avec la liste des tâches * carte maplibre ou leaflet synchronisée avec la liste des tâches
* possibilité de modifier les tâches en masse (actions groupées) * possibilité de modifier les tâches en masse (actions groupées)
* stockage des données dans sqlite * stockage des données dans sqlite
* presets dans le projet pour envoyer à JOSM (genre hashtags du message de commit, etc)
* presets dans le projet pour envoyer à JOSM (genre hashtags du message de
commit, etc)
On créé un projet (titre, description), on peut manipuler les tâches d'un On créé un projet (titre, description), on peut manipuler les tâches d'un
projet (ajouter par import geojson, supprimer, diviser en n×n ou en surface (on en déduit n)) et pour chacune
projet (ajouter par import geojson, supprimer, diviser en n×n ou en surface (on
en déduit n)) et pour chacune
d'elle on trouve un statut historicisé (à faire, en cours, fait) pour chaque d'elle on trouve un statut historicisé (à faire, en cours, fait) pour chaque
action (mapper, vérifier). On peut imagine que certains status lockent la tâche action (mapper, vérifier). On peut imagine que certains status lockent la tâche
qui se délocke au bout d'un certain temps. qui se délocke au bout d'un certain temps.
Peut-être préférer un workflow : à mapper → mappage en cours → mappage terminé à vérifier → vérification en cours → vérifié
Où les étapes *en cours* sont lockantes et reviennent au statut précédent au bout d'une journée
Peut-être préférer un workflow : à mapper → mappage en cours → mappage terminé
à vérifier → vérification en cours → vérifié
Où les étapes *en cours* sont lockantes et reviennent au statut précédent au
bout d'une journée
Un système de commentaires arborescent sur les tâches serait pas du luxe (lien
avec le statut via la date).
On peut faire des statistiques par projet sur les statuts des tâches. Et
rappeller les commentaires par ordre antéchronologique globalement sur le
projet.
En tous cas l'idéal serait de pouvoir faire tout ça via une api et de fournir
un client web en js moderne. Un client en ligne de commande serait pas du luxe
non plus.
## Mise en place technique
Il s’agit d’un petit projet Symfony (7.1.3) donc essentiellement en PHP
(développé avec la version 8.2.23) avec un peu de Javascript (utilisation du
framework Stimulus suggéré par Symfony) et de CSS (utilisation du framework
Bootstrap). Les données sont stockées dans une base SQLite localement.
Les dépendances PHP sont gérées assez classiquement par Composer. On peut donc
les récuperer avec un simple `composer install` dans la racine de
l’application.
On peut le faire tourner en local pour tester/développer grâce à l’outil en
ligne de commande [`symfony`](https://symfony.com/download) et notamment en
démarrant un serveur local : `symfony serve -d`.
L’application peut être servie par un serveur web (nginx, Apache, etc) comme
une application Symfony classique (la racine du serveur web étant dans
`/public`) pour peu qu’il interprète le PHP. Il n’y a pas de référence à des
noms de domaines donc pas de soucis pour les adresses web absolues.
La configuration de l’application se fait dans un fichier `.env.local`
(s’inspirer du `.env` fourni) dans lequel il faut essentiellement renseigner
les variables :
Un système de commentaires arborescent sur les tâches serait pas du luxe (lien avec le statut via la date).
* `APP_TIMEZONE` a priori `Europe/Paris`
* `OSM_CLIENT_ID` et `OSM_CLIENT_SECRET` à générer dans les options de son
compte OSM (onglet « application OAuth2 » avec comme URI de redirections
l’adresse web de son instance suffixée du chemin `/osm/callback` et comme
autorisation, uniquement « Lire les préférences de l’utilisateur »)
On peut faire des statistiques par projet sur les statuts des tâches. Et rappeller les commentaires par ordre antéchronologique globalement sur le projet.
En tous cas l'idéal serait de pouvoir faire tout ça via une api et de fournir un client web en js moderne. Un client en ligne de commande serait pas du luxe non plus.

+ 6
- 4
assets/app.js View File

@ -1,13 +1,15 @@
import './bootstrap.js';
import './bootstrap.js'; // Stimulus
import './vendor/bootstrap/dist/css/bootstrap.min.css';
import './vendor/leaflet/dist/leaflet.min.css';
import './styles/app.css';
import './vendor/bootstrap/dist/css/bootstrap.min.css'; // Bootstrap
import './vendor/leaflet/dist/leaflet.min.css'; // Leaflet
import './styles/app.css'; // Nos personnalisations
import { Tooltip } from './vendor/bootstrap/bootstrap.index.js'; import { Tooltip } from './vendor/bootstrap/bootstrap.index.js';
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
// Implémente une sécurité basique pour confirmer le clic sur tous les boutons
// qui emènent sur une adresse web susceptible de supprimer quelque-chose…
document.addEventListener('click', function (event) { document.addEventListener('click', function (event) {
if (event.target.matches('a[href*="remove"]')) { if (event.target.matches('a[href*="remove"]')) {
if (!confirm(event.target.innerText + ' ?')) { if (!confirm(event.target.innerText + ' ?')) {


+ 7
- 0
assets/controllers/clipboard_controller.js View File

@ -1,3 +1,10 @@
/**
Simple contrôleur pour copier du texte dans le presse papier en cliquant
simplement sur un bouton
Cf la macro twig `clipboard`
**/
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {


+ 37
- 1
assets/controllers/josm_controller.js View File

@ -1,3 +1,40 @@
/**
Implémente la télécommande JOSM
Le HTML géré pourrait ressembler à :
```html
<button
type="button"
data-controller="josm"
data-action="click->josm#remoteControl"
data-josm-commands-value="…"
>JOSM</button>
```
la valeur `commands` prend la forme dun objet JSON dont chacune des entrées
représente une commande à enchaîner successivement et la clef est le chemin
de la commande et la valeur un objet dont chacune des entrées représente les
paramètres de la commande.
Par exemple :
```json
{
"version": {},
"imagery": {
"id": "osmfr",
}
}
```
Avec cette valeur de `commands`, les appels successifs aux endpoints `/version`
puis `imagery?id=osmfr` seront effectués.
Cf <https://josm.openstreetmap.de/wiki/Help/RemoteControlCommands>
TODO Gérer le cas il ny a pas de JOSM en face pourrait être plus user-friendly.
**/
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
export default class extends Controller { export default class extends Controller {
@ -6,7 +43,6 @@ export default class extends Controller {
} }
remoteControl() { remoteControl() {
// cf <https://josm.openstreetmap.de/wiki/Help/RemoteControlCommands>
const baseurl = 'http://localhost:8111'; const baseurl = 'http://localhost:8111';
const _this = this, commands = JSON.parse(this.commandsValue); const _this = this, commands = JSON.parse(this.commandsValue);
for (var command in commands) { for (var command in commands) {


+ 45
- 11
assets/controllers/map_controller.js View File

@ -1,3 +1,12 @@
/**
Contrôleur qui gère la carte (charge son contenu et contrôle linteractivité)
Voir la macro Twig `map` qui produit le HTML géré par ce contrôleur.
Cf <https://leafletjs.com/reference.html>
**/
import { Controller } from '@hotwired/stimulus'; import { Controller } from '@hotwired/stimulus';
import 'leaflet'; import 'leaflet';
@ -10,13 +19,7 @@ export default class extends Controller {
} }
connect() { connect() {
const simpleIcon = L.icon({
iconUrl: this.iconValue,
iconSize: [16, 16],
iconAnchor: [8, 16],
popupAnchor: [0, 0],
});
// Constitue une collection d’icones aux couleurs Bootstrap
const iconHtml = ` const iconHtml = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-geo-alt-fill" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="bi bi-geo-alt-fill" viewBox="0 0 16 16">
<path fill="currentColor" d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6"/> <path fill="currentColor" d="M8 16s6-5.686 6-10A6 6 0 0 0 2 6c0 4.314 6 10 6 10m0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6"/>
@ -30,35 +33,50 @@ export default class extends Controller {
}; };
var geojsons, _this = this, map = L.map(this.element); var geojsons, _this = this, map = L.map(this.element);
// Commence par déclarer le fond de carte classique OSM par défaut
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map); }).addTo(map);
// Crée un ensemble de couches pour mieux les manipuler
// individuellement
var layer = L.featureGroup(); var layer = L.featureGroup();
// Crée la couche dédiée à Overpass
var overpassLayer = L.featureGroup(); var overpassLayer = L.featureGroup();
if (this.overpassResultValue !== '') { if (this.overpassResultValue !== '') {
geojsons = JSON.parse(this.overpassResultValue); geojsons = JSON.parse(this.overpassResultValue);
if (geojsons.elements.length > 0) { if (geojsons.elements.length > 0) {
// Ajoute chaque forme
geojsons.elements.forEach(function (element) { geojsons.elements.forEach(function (element) {
element.members.forEach(function (member) { element.members.forEach(function (member) {
// TODO On est parti du principe que les features du
// geojson sont toutes des polylines ce qui est un peu
// réducteur, à terme il faudrait distinguer les nœuds,
// des chemins, des polygones, etc.
const polygon = L.polyline(member.geometry.map(function (coord) { return L.latLng(coord.lat, coord.lon)}), { const polygon = L.polyline(member.geometry.map(function (coord) { return L.latLng(coord.lat, coord.lon)}), {
color: '#0dcaf0',
weight: 6,
color: '#0dcaf0', // bleu info bootstrap
weight: 6, // l’idée c’est que ce soit plus gros (par défaut c’est 3) que le tracé des données des tâches parce que ce sera en dessous
opacity: 0.8, opacity: 0.8,
}).addTo(overpassLayer).bindPopup(L.popup({ }).addTo(overpassLayer).bindPopup(L.popup({
overpassElement: element,
}).setContent('…'));
overpassElement: element, // on transmet les données du geojson à la popup par là
}).setContent('…')); // le contenu définitif de la popup sera chargé plus tard en ajax
}); });
}); });
// Intervient lors de l’ouverture de la popup associée à la forme
overpassLayer.on('popupopen', function (event) { overpassLayer.on('popupopen', function (event) {
// Récupère le geojson de la forme
var element = event.popup.options.overpassElement; var element = event.popup.options.overpassElement;
// Enlève ce qui nous est inutile (les points, etc)
delete element.members; delete element.members;
// Ajoute ce qui peut-être utile (concernant la carte0
element['map'] = { element['map'] = {
'center': map.getCenter(), 'center': map.getCenter(),
'zoom': map.getZoom(), 'zoom': map.getZoom(),
}; };
// Effectue l’appel ajax pour récupérer le contenu de la popup
fetch(_this.popupUrlValue + '?' + (new URLSearchParams({ fetch(_this.popupUrlValue + '?' + (new URLSearchParams({
'element': JSON.stringify(element), 'element': JSON.stringify(element),
}))) })))
@ -73,15 +91,25 @@ export default class extends Controller {
} }
} }
// Créé la couche dédiée aux tâches
var taskLayer = L.featureGroup(); var taskLayer = L.featureGroup();
geojsons = JSON.parse(this.geojsonValue); geojsons = JSON.parse(this.geojsonValue);
if (geojsons.length > 0) { if (geojsons.length > 0) {
geojsons.forEach(function (geojson) { geojsons.forEach(function (geojson) {
// TODO Ici aussi on ne distingue pas les géométries des
// features mais ça vaudrait peut-être le coup de s’adapter un
// peu à la situation en cas de nœud, chemin ou zone…
// Par facilité on conserve les propriétés de la première feature pour plus tard
const feature0 = geojson.features[0].properties; const feature0 = geojson.features[0].properties;
// Dessine la forme de la tâche avec la proprieté `name` qui
// s’ffiche au survol et cliquable vers l’adresse web dans la
// propriété `url`
const polygon = L.geoJSON(geojson, { const polygon = L.geoJSON(geojson, {
style: function (feature) { style: function (feature) {
// Par défaut c’est un bleu par défaut de leaflet mais
// sinon on utilise les couleurs de Bootstrap
var color = 'blue'; var color = 'blue';
switch (feature.properties.color) { switch (feature.properties.color) {
case 'danger' : color = '#dc3545'; break case 'danger' : color = '#dc3545'; break
@ -94,6 +122,8 @@ export default class extends Controller {
window.location.href = event.layer.feature.properties.url; window.location.href = event.layer.feature.properties.url;
}); });
// Ajoute un marqueur au centroïde de la forme (car les
// marqueurs gardent la même taille quelque-soit le zoom
L.marker(polygon.getBounds().getCenter(), { L.marker(polygon.getBounds().getCenter(), {
icon: icons[feature0.color], icon: icons[feature0.color],
title: feature0.name, title: feature0.name,
@ -108,6 +138,8 @@ export default class extends Controller {
layer.addTo(map); layer.addTo(map);
// Si la couche Overpass n’est pas vide, ajoute le sélecteur de couches
// sur la carte
if (this.overpassResultValue !== '') { if (this.overpassResultValue !== '') {
L.control.layers({}, { L.control.layers({}, {
'Overpass': overpassLayer, 'Overpass': overpassLayer,
@ -115,6 +147,8 @@ export default class extends Controller {
}).addTo(map); }).addTo(map);
} }
// Zoome la carte pour que les données des tâches soient toutes
// visibles
map.fitBounds(taskLayer.getBounds()); map.fitBounds(taskLayer.getBounds());
} }
} }

+ 1
- 0
assets/styles/app.css View File

@ -1,3 +1,4 @@
/* Force la hauteur à un minimum de 50% de la hauteur du viewport */
.min-vh-50 { .min-vh-50 {
min-height: 50vh; min-height: 50vh;
} }

+ 8
- 2
src/Controller/HomeController.php View File

@ -12,6 +12,7 @@ use Symfony\Component\Routing\Attribute\Route;
class HomeController extends AbstractController class HomeController extends AbstractController
{ {
// Page d’accueil du site
#[Route('/', name: 'app_home')] #[Route('/', name: 'app_home')]
public function index(): Response public function index(): Response
{ {
@ -19,10 +20,12 @@ class HomeController extends AbstractController
]); ]);
} }
// Page d’erreur
public function error(Request $request, $exception, $logger = null): Response public function error(Request $request, $exception, $logger = null): Response
{ {
$this->addFlash('danger', $exception->getMessage()); // . ' ' . $exception->getFile() . ':' . $exception->getLine()); $this->addFlash('danger', $exception->getMessage()); // . ' ' . $exception->getFile() . ':' . $exception->getLine());
// Si le referer est renseigné on renvoie vers celui-ci où le message d’erreur sera affiché
if ($request->headers->has('Referer')) { if ($request->headers->has('Referer')) {
return $this->redirect($request->headers->get('Referer')); return $this->redirect($request->headers->get('Referer'));
} }
@ -31,16 +34,18 @@ class HomeController extends AbstractController
]); ]);
} }
// L’oauth OSM commence ici
#[Route('/osm/request', name: 'app_osm_request')] #[Route('/osm/request', name: 'app_osm_request')]
public function osmRequest(ClientRegistry $clientRegistry): Response public function osmRequest(ClientRegistry $clientRegistry): Response
{ {
return $clientRegistry return $clientRegistry
->getClient('openstreetmap')
->getClient('openstreetmap') // cf `config/packages/knpu_oauth2_client.yaml`
->redirect([ ->redirect([
'read_prefs',
'read_prefs', // TODO est-ce qu’on a même besoin de ça ?
]); ]);
} }
// L’oauth OSM aboutit ici
#[Route('/osm/callback', name: 'app_osm_callback')] #[Route('/osm/callback', name: 'app_osm_callback')]
public function osmCallback(Request $request, ClientRegistry $clientRegistry): Response public function osmCallback(Request $request, ClientRegistry $clientRegistry): Response
{ {
@ -58,6 +63,7 @@ class HomeController extends AbstractController
return $this->redirectToRoute('app_home'); return $this->redirectToRoute('app_home');
} }
// La déconnexion passe par là mais c’est pas lié à l’oauth
#[Route('/osm/logout', name: 'app_osm_logout')] #[Route('/osm/logout', name: 'app_osm_logout')]
public function osmLogout(Security $security): Response public function osmLogout(Security $security): Response
{ {


+ 3
- 0
src/Controller/MapController.php View File

@ -10,9 +10,12 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/map')] #[Route('/map')]
class MapController extends AbstractController class MapController extends AbstractController
{ {
// Produit le contenu de la popup (quand on clique sur un tracé issu d’overpass)
#[Route('/popup', name: 'app_map_popup')] #[Route('/popup', name: 'app_map_popup')]
public function popup(Request $request): Response public function popup(Request $request): Response
{ {
// Grosso modo, on récuère les données de la feature correspondante
// Cf `assets/controllers/map_controller.js`
$element = json_decode($request->query->get('element'), true); $element = json_decode($request->query->get('element'), true);
$josmCommands = [ $josmCommands = [


+ 16
- 27
src/Controller/ProjectController.php View File

@ -19,6 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ProjectController extends AbstractController class ProjectController extends AbstractController
{ {
// Page des projets, où l'on voit tous les projets
#[Route('/', name: 'app_project')] #[Route('/', name: 'app_project')]
public function index(EntityManagerInterface $entityManager): Response public function index(EntityManagerInterface $entityManager): Response
{ {
@ -29,6 +30,7 @@ class ProjectController extends AbstractController
]); ]);
} }
// Formulaire de création d’un projet
#[Route('/create', name: 'app_project_create')] #[Route('/create', name: 'app_project_create')]
public function create(Request $request, EntityManagerInterface $entityManager): Response public function create(Request $request, EntityManagerInterface $entityManager): Response
{ {
@ -47,17 +49,11 @@ class ProjectController extends AbstractController
$entityManager->persist($project); $entityManager->persist($project);
$entityManager->flush(); $entityManager->flush();
$this->addFlash(
'success',
'Projet créé !'
);
$this->addFlash('success', 'Projet créé !');
return $this->redirectToRoute('app_project'); return $this->redirectToRoute('app_project');
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->addFlash(
'danger',
'Impossible de créer le projet !'
);
$this->addFlash('danger', 'Impossible de créer le projet !');
} }
} }
@ -66,6 +62,7 @@ class ProjectController extends AbstractController
]); ]);
} }
// Page d’un prtojet donné (où l’on voit ses tâches)
#[Route('/{slug}', name: 'app_project_show')] #[Route('/{slug}', name: 'app_project_show')]
public function show(EntityManagerInterface $entityManager, $slug): Response public function show(EntityManagerInterface $entityManager, $slug): Response
{ {
@ -77,14 +74,14 @@ class ProjectController extends AbstractController
} }
$tasks = $entityManager->getRepository(Task::class)->findByProjectPaginated($project); $tasks = $entityManager->getRepository(Task::class)->findByProjectPaginated($project);
$randomTask = $entityManager->getRepository(Task::class)->findRandomByProject($project, Task::STATUS_TODO);
$randomTask = $entityManager->getRepository(Task::class)->findRandomByProject($project, Task::STATUS_TODO); // Trouve la tâche à faire que l’on piochera
$csvForm = $this->createForm(CsvType::class, null, [ $csvForm = $this->createForm(CsvType::class, null, [
'action' => $this->generateUrl('app_project_import', ['slug' => $slug]) 'action' => $this->generateUrl('app_project_import', ['slug' => $slug])
]); ]);
$csvForm->add('submit', SubmitType::class, ['label' => 'Importer']); $csvForm->add('submit', SubmitType::class, ['label' => 'Importer']);
$comments = $entityManager->getRepository(Comment::class)->findLatestByProject($project);
$comments = $entityManager->getRepository(Comment::class)->findLatestByProject($project); // Commentaires agrégés de toutes les tâches du projet
return $this->render('project/show.html.twig', [ return $this->render('project/show.html.twig', [
'project' => $project, 'project' => $project,
@ -95,6 +92,7 @@ class ProjectController extends AbstractController
]); ]);
} }
// Import de tâches dans un projet
#[Route('/{slug}/import', name: 'app_project_import')] #[Route('/{slug}/import', name: 'app_project_import')]
public function import(Request $request, EntityManagerInterface $entityManager, $slug): Response public function import(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -112,10 +110,10 @@ class ProjectController extends AbstractController
if ($csvForm->isSubmitted() and $csvForm->isValid()) { if ($csvForm->isSubmitted() and $csvForm->isValid()) {
$csvFile = $csvForm->get('csv')->getData(); $csvFile = $csvForm->get('csv')->getData();
// TODO un peu plus de gestion d’erreur ici serait pas du luxe…
$csv = fopen($csvFile->getPathName(), 'r'); $csv = fopen($csvFile->getPathName(), 'r');
$col = array_flip(fgetcsv($csv)); $col = array_flip(fgetcsv($csv));
while ($row = fgetcsv($csv)) { while ($row = fgetcsv($csv)) {
$task = new Task(); $task = new Task();
$task->setCreatedBy($this->getUser()); $task->setCreatedBy($this->getUser());
$task->setProject($project); $task->setProject($project);
@ -140,6 +138,7 @@ class ProjectController extends AbstractController
return $this->redirectToRoute('app_project_show', ['slug' => $slug]); return $this->redirectToRoute('app_project_show', ['slug' => $slug]);
} }
// Page de modification du projet
#[Route('/{slug}/update', name: 'app_project_update')] #[Route('/{slug}/update', name: 'app_project_update')]
public function update(Request $request, EntityManagerInterface $entityManager, $slug): Response public function update(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -147,10 +146,7 @@ class ProjectController extends AbstractController
$project = $repository->findOneBySlug($slug); $project = $repository->findOneBySlug($slug);
if (!$project) { if (!$project) {
$this->addFlash(
'warning',
'Projet non trouvé !'
);
$this->addFlash('warning', 'Projet non trouvé !');
return $this->redirectToRoute('app_project'); return $this->redirectToRoute('app_project');
} }
@ -168,17 +164,11 @@ class ProjectController extends AbstractController
$entityManager->persist($project); $entityManager->persist($project);
$entityManager->flush(); $entityManager->flush();
$this->addFlash(
'success',
'Projet modifié !'
);
$this->addFlash('success', 'Projet modifié !');
return $this->redirectToRoute('app_project_show', ['slug' => $slug]); return $this->redirectToRoute('app_project_show', ['slug' => $slug]);
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->addFlash(
'danger',
'Impossible de mopdifier le projet !'
);
$this->addFlash('danger', 'Impossible de mopdifier le projet !');
} }
} }
@ -188,6 +178,7 @@ class ProjectController extends AbstractController
]); ]);
} }
// La suppression d’un projet passe par là
#[Route('/{slug}/remove', name: 'app_project_remove')] #[Route('/{slug}/remove', name: 'app_project_remove')]
public function remove(EntityManagerInterface $entityManager, $slug): Response public function remove(EntityManagerInterface $entityManager, $slug): Response
{ {
@ -206,15 +197,13 @@ class ProjectController extends AbstractController
return $this->redirectToRoute('app_project'); return $this->redirectToRoute('app_project');
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->addFlash(
'danger',
'Impossible de supprimer le projet !'
);
$this->addFlash('danger', 'Impossible de supprimer le projet !');
} }
return $this->redirectToRoute('app_project'); return $this->redirectToRoute('app_project');
} }
// Effectue la requête Overpass liée au projet
#[Route('/{slug}/overpass', name: 'app_project_overpass')] #[Route('/{slug}/overpass', name: 'app_project_overpass')]
public function overpass(OverpassClient $overpassClient, EntityManagerInterface $entityManager, $slug): Response public function overpass(OverpassClient $overpassClient, EntityManagerInterface $entityManager, $slug): Response
{ {


+ 39
- 73
src/Controller/TaskController.php View File

@ -24,6 +24,7 @@ use Symfony\Component\Workflow\WorkflowInterface;
#[Route('/task')] #[Route('/task')]
class TaskController extends AbstractController class TaskController extends AbstractController
{ {
// Page de créatiom d’une tâche
#[Route('/create', name: 'app_task_create')] #[Route('/create', name: 'app_task_create')]
public function create(Request $request, EntityManagerInterface $entityManager): Response public function create(Request $request, EntityManagerInterface $entityManager): Response
{ {
@ -61,10 +62,7 @@ class TaskController extends AbstractController
$entityManager->persist($task); $entityManager->persist($task);
$entityManager->flush(); $entityManager->flush();
$this->addFlash(
'success',
'Tâche créée !'
);
$this->addFlash('success', 'Tâche créée !');
return $this->redirectToRoute('app_project_show', ['slug' => $slug]); return $this->redirectToRoute('app_project_show', ['slug' => $slug]);
} catch (\Exception $exception) { } catch (\Exception $exception) {
@ -79,6 +77,7 @@ class TaskController extends AbstractController
]); ]);
} }
// Page spécifique à une tâche, où l’on trouve tout ce qui la concerne
#[Route('/{slug}', name: 'app_task_show')] #[Route('/{slug}', name: 'app_task_show')]
public function show(Request $request, EntityManagerInterface $entityManager, GeoJsonManager $geoJsonManager, $slug): Response public function show(Request $request, EntityManagerInterface $entityManager, GeoJsonManager $geoJsonManager, $slug): Response
{ {
@ -86,10 +85,7 @@ class TaskController extends AbstractController
$task = $repository->findOneBySlug($slug); $task = $repository->findOneBySlug($slug);
if (!$task) { if (!$task) {
$this->addFlash(
'warning',
'Tâche non trouvée !'
);
$this->addFlash('warning', 'Tâche non trouvée !');
if (!$request->headers->has('Referer')) { if (!$request->headers->has('Referer')) {
throw $this->createNotFoundException('Task not found'); throw $this->createNotFoundException('Task not found');
@ -108,10 +104,15 @@ class TaskController extends AbstractController
'label' => 'Commenter', 'label' => 'Commenter',
]); ]);
// Programmation de la télécommande JOSM
$josmCommands = [ $josmCommands = [
// Charge l’imagerie (fond OSM par défaut)
'imagery' => [ 'imagery' => [
'id' => $project->hasImagery() ? $project->getImagery() : 'osmfr', 'id' => $project->hasImagery() ? $project->getImagery() : 'osmfr',
], ],
// Charge le XML OSM du projet danms un calque de données
// TODO C’est susceptible de planter s’il n’y a pas d’OSM dans le projet mais est-ce possible ?
'import' => [ 'import' => [
'new_layer' => true, 'new_layer' => true,
'layer_name' => $task->getName(), 'layer_name' => $task->getName(),
@ -127,6 +128,7 @@ class TaskController extends AbstractController
$geom = \geoPHP::load($task->getGeojson(), 'json'); $geom = \geoPHP::load($task->getGeojson(), 'json');
$bbox = $geom->getBBox(); $bbox = $geom->getBBox();
// Zoome sur les données et préremplit le changeset
$josmCommands['zoom'] = [ $josmCommands['zoom'] = [
'bottom' => $bbox['miny'], 'bottom' => $bbox['miny'],
'top' => $bbox['maxy'], 'top' => $bbox['maxy'],
@ -147,6 +149,7 @@ class TaskController extends AbstractController
]); ]);
} }
// Ajoute un commentaire à la tâche
#[Route('/{slug}/comment', name: 'app_task_comment')] #[Route('/{slug}/comment', name: 'app_task_comment')]
public function comment(Request $request, EntityManagerInterface $entityManager, $slug): Response public function comment(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -154,10 +157,7 @@ class TaskController extends AbstractController
$task = $repository->findOneBySlug($slug); $task = $repository->findOneBySlug($slug);
if (!$task) { if (!$task) {
$this->addFlash(
'warning',
'Tâche non trouvée !'
);
$this->addFlash('warning', 'Tâche non trouvée !');
return $this->redirect($request->headers->get('Referer')); return $this->redirect($request->headers->get('Referer'));
} }
@ -179,16 +179,14 @@ class TaskController extends AbstractController
$entityManager->flush(); $entityManager->flush();
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->addFlash(
'danger',
'Impossible de commenter ! ' . $exception->getMessage()
);
$this->addFlash('danger', 'Impossible de commenter ! ' . $exception->getMessage());
} }
} }
return $this->redirectToRoute('app_task_show', ['slug' => $slug]); return $this->redirectToRoute('app_task_show', ['slug' => $slug]);
} }
// Modifie les informations d’une tâche
#[Route('/{slug}/update', name: 'app_task_update')] #[Route('/{slug}/update', name: 'app_task_update')]
public function update(Request $request, EntityManagerInterface $entityManager, $slug): Response public function update(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -196,10 +194,7 @@ class TaskController extends AbstractController
$task = $repository->findOneBySlug($slug); $task = $repository->findOneBySlug($slug);
if (!$task) { if (!$task) {
$this->addFlash(
'warning',
'Tâche non trouvée !'
);
$this->addFlash('warning', 'Tâche non trouvée !');
return $this->redirect($request->headers->get('Referer')); return $this->redirect($request->headers->get('Referer'));
} }
@ -219,10 +214,7 @@ class TaskController extends AbstractController
return $this->redirectToRoute('app_task_show', ['slug' => $slug]); return $this->redirectToRoute('app_task_show', ['slug' => $slug]);
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->addFlash(
'danger',
'Impossible de modifier la tâche !'
);
$this->addFlash('danger', 'Impossible de modifier la tâche !');
} }
} }
@ -233,6 +225,7 @@ class TaskController extends AbstractController
]); ]);
} }
// Supprimer une tâche
#[Route('/{slug}/remove', name: 'app_task_remove')] #[Route('/{slug}/remove', name: 'app_task_remove')]
public function remove(Request $request, EntityManagerInterface $entityManager, $slug): Response public function remove(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -240,10 +233,7 @@ class TaskController extends AbstractController
$task = $repository->findOneBySlug($slug); $task = $repository->findOneBySlug($slug);
if (!$task) { if (!$task) {
$this->addFlash(
'warning',
'Tâche non trouvée !'
);
$this->addFlash('warning', 'Tâche non trouvée !');
return $this->redirect($request->headers->get('Referer')); return $this->redirect($request->headers->get('Referer'));
} }
@ -256,25 +246,20 @@ class TaskController extends AbstractController
return $this->redirectToRoute('app_project_show', ['slug' => $project->getSlug()]); return $this->redirectToRoute('app_project_show', ['slug' => $project->getSlug()]);
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->addFlash(
'danger',
'Impossible de supprimer la tâche !'
);
$this->addFlash('danger', 'Impossible de supprimer la tâche !');
} }
return $this->redirectToRoute('app_project_show', ['slug' => $slug]); return $this->redirectToRoute('app_project_show', ['slug' => $slug]);
} }
// Passe une tâche d’un état à un autre
private function transition(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug, $transitionName): Response private function transition(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug, $transitionName): Response
{ {
$repository = $entityManager->getRepository(Task::class); $repository = $entityManager->getRepository(Task::class);
$task = $repository->findOneBySlug($slug); $task = $repository->findOneBySlug($slug);
if (!$task) { if (!$task) {
$this->addFlash(
'warning',
'Tâche non trouvée !'
);
$this->addFlash('warning', 'Tâche non trouvée !');
return $this->redirectToRoute('app_project'); return $this->redirectToRoute('app_project');
} }
@ -285,39 +270,41 @@ class TaskController extends AbstractController
$entityManager->flush(); $entityManager->flush();
} catch (Exception $exception) { } catch (Exception $exception) {
$this->addFlash(
'warning',
'Impossible de modifier la tâche !'
);
$this->addFlash('warning', 'Impossible de modifier la tâche !');
} }
return $this->redirectToRoute('app_task_show', ['slug' => $slug]); return $this->redirectToRoute('app_task_show', ['slug' => $slug]);
} }
// Commence une tâche
#[Route('/{slug}/start', name: 'app_task_start')] #[Route('/{slug}/start', name: 'app_task_start')]
public function start(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response public function start(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response
{ {
return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_START); return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_START);
} }
// Termine une tâche
#[Route('/{slug}/finish', name: 'app_task_finish')] #[Route('/{slug}/finish', name: 'app_task_finish')]
public function finish(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response public function finish(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response
{ {
return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_FINISH); return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_FINISH);
} }
// Abandonne une tâche
#[Route('/{slug}/cancel', name: 'app_task_cancel')] #[Route('/{slug}/cancel', name: 'app_task_cancel')]
public function cancel(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response public function cancel(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response
{ {
return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_CANCEL); return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_CANCEL);
} }
// Recommence une tâche
#[Route('/{slug}/reset', name: 'app_task_reset')] #[Route('/{slug}/reset', name: 'app_task_reset')]
public function reset(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response public function reset(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $slug): Response
{ {
return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_RESET); return $this->transition($taskLifecycleStateMachine, $entityManager, $slug, Task::TRANSITION_RESET);
} }
// Renvoie le geojson associé à une tâche
#[Route('/download/{slug}.geojson', name: 'app_task_geojson')] #[Route('/download/{slug}.geojson', name: 'app_task_geojson')]
public function geojson(Request $request, EntityManagerInterface $entityManager, $slug): Response public function geojson(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -325,11 +312,8 @@ class TaskController extends AbstractController
$task = $repository->findOneBySlug($slug); $task = $repository->findOneBySlug($slug);
if (!$task) { if (!$task) {
$this->addFlash(
'warning',
'Tâche non trouvée !'
);
// TODO faire pareil ailleurs où il y a des referer
$this->addFlash('warning', 'Tâche non trouvée !');
if (!$request->headers->has('Referer')) { if (!$request->headers->has('Referer')) {
throw $this->createNotFoundException('Task not found'); throw $this->createNotFoundException('Task not found');
} }
@ -350,6 +334,7 @@ class TaskController extends AbstractController
return $response; return $response;
} }
// Renvoie le gpx associé ã une tâche (concrètement il s’agit juste du geojson converti automqtiquement)
#[Route('/download/{slug}.gpx', name: 'app_task_gpx')] #[Route('/download/{slug}.gpx', name: 'app_task_gpx')]
public function gpx(Request $request, EntityManagerInterface $entityManager, $slug): Response public function gpx(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -371,6 +356,10 @@ class TaskController extends AbstractController
$geom = \geoPHP::load($task->getGeojson(), 'json'); $geom = \geoPHP::load($task->getGeojson(), 'json');
$gpx = $geom->out('gpx'); $gpx = $geom->out('gpx');
// TODO On ne vérifie rien ici en partant du principe que tout se passe
// bien. Il doit y avoir des circonstances dans lesquelles ce n’est pas
// exactement le cas.
$response = new Response($gpx); $response = new Response($gpx);
$response->headers->set('Content-Type', 'application/xml'); $response->headers->set('Content-Type', 'application/xml');
@ -382,6 +371,7 @@ class TaskController extends AbstractController
return $response; return $response;
} }
// Renvoie le XML OSM associé à la tâche
#[Route('/download/{slug}.osm', name: 'app_task_osm')] #[Route('/download/{slug}.osm', name: 'app_task_osm')]
public function osm(Request $request, EntityManagerInterface $entityManager, $slug): Response public function osm(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -396,6 +386,8 @@ class TaskController extends AbstractController
return $this->redirect($request->headers->get('referer')); return $this->redirect($request->headers->get('referer'));
} }
// On est pas obligé de faire ça maid en le faisant on s’assure que
// c’est bien du XML valide qu’on envoie.
$xml = new \DOMDocument(); $xml = new \DOMDocument();
$xml->loadXml($task->getOsm()); $xml->loadXml($task->getOsm());
@ -410,6 +402,8 @@ class TaskController extends AbstractController
return $response; return $response;
} }
// Renvoie la liste des tâches du projet sous forme de CSV (ce qui devrait
// corresponddre à ce que l’on a pu importer)
#[Route('/download/{slug}.csv', name: 'app_task_csv')] #[Route('/download/{slug}.csv', name: 'app_task_csv')]
public function csv(Request $request, EntityManagerInterface $entityManager, $slug): Response public function csv(Request $request, EntityManagerInterface $entityManager, $slug): Response
{ {
@ -459,32 +453,4 @@ class TaskController extends AbstractController
return $response; return $response;
} }
#[Route('/{slug}/changesets', name: 'app_task_changesets')]
public function changesets(OpenStreetMapClient $osmClient, EntityManagerInterface $entityManager, $slug): Response
{
$repository = $entityManager->getRepository(Task::class);
$task = $repository->findOneBySlug($slug);
if (!$task) {
$this->addFlash('warning', 'Tâche non trouvée !');
return $this->redirect($request->headers->get('referer'));
}
$task->setChangesetsResult(
json_encode(
$osmClient->getChangesets(
$task->getDoneBy()->getUsername(),
$task->getStartAt(),
$task->getFinishAt()
)
)
);
$entityManager->persist($task);
$entityManager->flush();
return $this->redirectToRoute('app_task_show', ['slug' => $task->getSlug()]);
}
} }

+ 2
- 1
src/DataFixtures/AppFixtures.php View File

@ -9,11 +9,12 @@ use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
// Les données ci-dessous n’ont servi que lors du premier import pour gérer
// l’historique mais n’ont strictement aucun intérêt au delà.
class AppFixtures extends Fixture class AppFixtures extends Fixture
{ {
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
$user = new User(); $user = new User();
$user->setUsername('Ptigrouick'); $user->setUsername('Ptigrouick');
$user->setOsmId(195175); $user->setOsmId(195175);


+ 3
- 0
src/Entity/Comment.php View File

@ -7,6 +7,9 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo; use Gedmo\Mapping\Annotation as Gedmo;
// Les commentaires sont rédigés par les utilisateurs et associés aux tâches.
// Ils sont égelement générés automatiquements lors des changements d’états des
// tâches.
#[ORM\Entity(repositoryClass: CommentRepository::class)] #[ORM\Entity(repositoryClass: CommentRepository::class)]
class Comment class Comment
{ {


+ 2
- 0
src/Entity/Project.php View File

@ -9,6 +9,8 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo; use Gedmo\Mapping\Annotation as Gedmo;
// Les projets sont des ensembles de tâches avec des paramètres de
// configuration pour la télécommande JOSM.
#[ORM\Entity(repositoryClass: ProjectRepository::class)] #[ORM\Entity(repositoryClass: ProjectRepository::class)]
class Project class Project
{ {


+ 2
- 0
src/Entity/Tag.php View File

@ -7,6 +7,8 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
// On utilise les tags pour étiqueter les projets, histoire de les classer le
// jour où il y en aura beaucoup.
#[ORM\Entity(repositoryClass: TagRepository::class)] #[ORM\Entity(repositoryClass: TagRepository::class)]
class Tag class Tag
{ {


+ 2
- 0
src/Entity/Task.php View File

@ -9,6 +9,8 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo; use Gedmo\Mapping\Annotation as Gedmo;
// Les tâches sont l’unité de base du travail à faire. Elles sont groupées dans
// des projets et peut être appropriés par les utilisateurs selon leur état.
#[ORM\Entity(repositoryClass: TaskRepository::class)] #[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task class Task
{ {


+ 2
- 0
src/Entity/User.php View File

@ -8,6 +8,8 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
// Les utilisateurs sont générés automatiquements via l’oauth OSM et peuvent
// manipuler des projets et des tâches.
#[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_USERNAME', fields: ['username'])]
class User implements UserInterface class User implements UserInterface


+ 14
- 0
src/EventSubscriber/TaskLifecycleSubscriber.php View File

@ -15,6 +15,8 @@ use Symfony\Component\Workflow\Event\TransitionEvent;
use Symfony\Component\Workflow\WorkflowInterface; use Symfony\Component\Workflow\WorkflowInterface;
use Twig\Environment; use Twig\Environment;
// Surveille la vie des tâches et réalise automatiquement des actions selon les
// circonstances
class TaskLifecycleSubscriber implements EventSubscriberInterface class TaskLifecycleSubscriber implements EventSubscriberInterface
{ {
@ -27,6 +29,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface
) { ) {
} }
// Cas où la tâche change d’état
public function onTransition(Event $event): void public function onTransition(Event $event): void
{ {
$transition = $event->getTransition(); $transition = $event->getTransition();
@ -57,6 +60,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface
$this->entityManager->flush(); $this->entityManager->flush();
} }
// Bloque certaines transitions
public function onGuard(Event $event): void public function onGuard(Event $event): void
{ {
$transition = $event->getTransition(); $transition = $event->getTransition();
@ -76,6 +80,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface
$event->setBlocked(false); $event->setBlocked(false);
} }
// Cas où on commence une tâche
public function onStart(Event $event): void public function onStart(Event $event): void
{ {
$task = $event->getSubject(); $task = $event->getSubject();
@ -83,6 +88,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface
$task->setStartAt(new \DateTimeImmutable('now')); $task->setStartAt(new \DateTimeImmutable('now'));
} }
// Cas où on termine une tâche
public function onFinish(Event $event): void public function onFinish(Event $event): void
{ {
$task = $event->getSubject(); $task = $event->getSubject();
@ -90,6 +96,7 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface
$task->setFinishAt(new \DateTimeImmutable('now')); $task->setFinishAt(new \DateTimeImmutable('now'));
} }
// Cas où une tâche est terminée
public function onDone(Event $event): void public function onDone(Event $event): void
{ {
$task = $event->getSubject(); $task = $event->getSubject();
@ -97,8 +104,15 @@ class TaskLifecycleSubscriber implements EventSubscriberInterface
$task->setDoneBy($user); $task->setDoneBy($user);
// Essaie de trouver les changesets correspondants à la tâche
$task->setChangesetsResult( $task->setChangesetsResult(
json_encode($this->osmClient->getChangesets( json_encode($this->osmClient->getChangesets(
// On part du principe que les changesets correspondants sont
// ceux de l’utilisateur entre le moment où la tâche a été
// commencée et celui où elle a été terminée
// TODO C’est pas très précis, on doit pouvoir faire mieux
// (hashatg avec un id spécifique à l’app dans le commentaire
// du changeset, c’est moche mais ça marcherait)
$user->getUsername(), $user->getUsername(),
$task->getStartAt(), $task->getStartAt(),
$task->getFinishAt() $task->getFinishAt()


+ 1
- 1
src/Form/TaskType.php View File

@ -24,7 +24,7 @@ class TaskType extends AbstractType
'label' => 'GeoJSON', 'label' => 'GeoJSON',
'required' => false, 'required' => false,
'help_html' => true, 'help_html' => true,
'help' => 'Ce qu’il faut dessinner sur la carte au format <a href="https://fr.wikipedia.org/wiki/GeoJSON" target="_blank">GeoJSON</a> et mettre à disposition à l’export sous forme de <a href="https://fr.wikipedia.org/wiki/GPX_(format_de_fichier)" target="_blank">GPX</a> pour l’import dans JOSM. Outil pratique&nbsp;: <a href="ttps://geojson.io/" target="_blank">geojson.io</a>.',
'help' => 'Ce qu’il faut dessiner sur la carte au format <a href="https://fr.wikipedia.org/wiki/GeoJSON" target="_blank">GeoJSON</a> et mettre à disposition à l’export sous forme de <a href="https://fr.wikipedia.org/wiki/GPX_(format_de_fichier)" target="_blank">GPX</a> pour l’import dans JOSM. Outil pratique&nbsp;: <a href="ttps://geojson.io/" target="_blank">geojson.io</a>.',
]) ])
->add('osm', TextareaType::class, [ ->add('osm', TextareaType::class, [
'label' => 'OSM', 'label' => 'OSM',


+ 2
- 0
src/Service/GeoJsonManager.php View File

@ -17,6 +17,7 @@ class GeoJsonManager
) { ) {
} }
// Enrichit le geojson d’une tâche avec des propriétés calcuées
private function getFullGeoJson(Task $task) private function getFullGeoJson(Task $task)
{ {
if (!$task->hasGeojson()) { if (!$task->hasGeojson()) {
@ -48,6 +49,7 @@ class GeoJsonManager
return $data; return $data;
} }
// Produit le geojson d’un projet ou d’une tâche de façon transparente pour simplifier
public function generateGeoJson($entity) public function generateGeoJson($entity)
{ {
$geoJsons = []; $geoJsons = [];


+ 1
- 0
src/Service/OpenStreetMapClient.php View File

@ -6,6 +6,7 @@ use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
// Fournit un accès à l’API OSM
class OpenStreetMapClient { class OpenStreetMapClient {
public function __construct( public function __construct(


+ 3
- 0
src/Service/OsmoseClient.php View File

@ -19,6 +19,9 @@ namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
// Fournira un accès à l’API Osmose si nécessaire (histoire de vérifier qu’on a
// pas trop oublié de tenir compte de la validation côté JOSM lors de la
// contribution sur une tâche ?)
class OsmoseClient { class OsmoseClient {
public function __construct( public function __construct(


+ 1
- 0
src/Service/OverpassClient.php View File

@ -4,6 +4,7 @@ namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
// Founit un accès à l’API Overpass
class OverpassClient { class OverpassClient {
public function __construct( public function __construct(


+ 1
- 0
src/Service/SourceGenerator.php View File

@ -4,6 +4,7 @@ namespace App\Service;
use App\Entity\Task; use App\Entity\Task;
// Génère la source de changeset
class SourceGenerator class SourceGenerator
{ {


+ 1
- 0
src/Service/TaskLifecycleManager.php View File

@ -6,6 +6,7 @@ use App\Entity\Project;
use App\Entity\Task; use App\Entity\Task;
use Symfony\Component\Workflow\WorkflowInterface; use Symfony\Component\Workflow\WorkflowInterface;
// Utilistaire pour récupérer facilement les stats de réalisation d’un projet ou d’une tâche
class TaskLifecycleManager class TaskLifecycleManager
{ {


+ 1
- 1
templates/_header.html.twig View File

@ -1,7 +1,7 @@
<header> <header>
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ path('app_home') }}">{{ title }}</a>
<a class="navbar-brand" href="{{ path('app_home') }}" title="{{ long_title }}">{{ title }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar" aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>


+ 22
- 0
templates/macro.html.twig View File

@ -19,6 +19,17 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{#
Produit le HTML géré par le contrôleur Stimulus `map` et permet d’afficher une
carte interactive
Où :
* `entity` peut être une instance de `Project` ou de `Task` pour représenter ce
qu’il faut mapper
* `overpassResult` est optionnel pour représenter ce qui est actuellement mappé
#}
{% macro map(entity, overpassResult='') %} {% macro map(entity, overpassResult='') %}
<div <div
id="map" id="map"
@ -41,6 +52,17 @@
</details> </details>
{% endmacro %} {% endmacro %}
{#
Génère le HTML qui va bien pour un bloc de texte que l’on peut copier dans le
presse papier en cliquant simplement sur un bouton.
Cf le controlleur Stimulus `clipboard`
TODO Rajouter un retour visuel pour signifier que la copie a bien eu lieu
serait pas du luxe…
#}
{% macro clipboard(text) %} {% macro clipboard(text) %}
<div class="input-group" data-controller="clipboard"> <div class="input-group" data-controller="clipboard">
<input type="text" readonly class="form-control" value="{{ text }}" data-clipboard-target="source" /> <input type="text" readonly class="form-control" value="{{ text }}" data-clipboard-target="source" />


Loading…
Cancel
Save