Upgrade to Pro — share decks privately, control downloads, hide ads and more …

L'Open-Source au service de la campagne d'Emmanuel Macron

L'Open-Source au service de la campagne d'Emmanuel Macron

Ce retour d'expérience vous emmènera dans l'envers du décor technique de la campagne victorieuse d'Emmanuel Macron aux élections présidentielles françaises de 2017. Nous vous révèlerons comment les équipes d'En-Marche et de SensioLabs ont étroitement collaboré pour développer une plateforme collaborative et citoyenne Open-Source avec PHP 7 et Symfony pour structurer le mouvement.

En complément des enjeux techniques et politiques de ce projet, nous vous présenterons les solutions adoptées comme le déploiement continu avec Docker sous Kubernetes, l'intégration continue avec Circle CI, la gestion de projet SCRUM et Kanban, l'utilisation de Rabbitmq, les dernières nouveautés de Symfony, et bien plus encore.

Nous vous montrerons l'importance du choix de ces solutions pour répondre aux besoins d'agilité et de scalabilité des temps forts de la campagne.

Hugo Hamon

June 20, 2017
Tweet

More Decks by Hugo Hamon

Other Decks in Programming

Transcript

  1. Les contraintes de la campagne 1.  Respecter des délais incompressibles

    2.  S’adapter à l’actualité 3.  Proposer des fonctionnalités innovantes 4.  Riposter aux attaques des autres candidats
  2. Le besoin de la plateforme Accepter les dons Enregistrer les

    adhésions Faciliter la communication
  3. Les enjeux techniques •  Disponibilité continue de la plateforme • 

    Envois massifs d’e-mails •  Sécurité de l’infrastructure et des données
  4. Octobre 2016 Titouan rejoint le mouvement politique En- Marche et

    initie la refonte du site Internet sous Symfony.
  5. Décembre 2016 Un site de contenu plus moderne Variables d’environnement

    avec Symfony 3.2 parameters: env(DATABASE_HOST): db env(DATABASE_PORT): 3306 env(DATABASE_SOCKET): null env(DATABASE_NAME): enmarche env(DATABASE_USER): root env(DATABASE_PASSWORD): root
  6. trait EntityIdentityTrait { /** * @ORM\Id * @ORM\Column(type="integer", options={"unsigned": true})

    * @ORM\GeneratedValue */ protected $id; /** * @ORM\Column(type="uuid") * @var Ramsey\Uuid\UuidInterface */ protected $uuid; // ... + getters } Janvier 2017 L’ancienne plateforme révèle ses limites Entités Doctrine : ID vs UUIDs
  7. Janvier 2017 L’ancienne plateforme révèle ses limites Gestion des adhésions

    et donations class MembershipController extends Controller { // ... public function registerAction(Request $request): Response { $membership = MembershipRequest::createWithCaptcha($request->request->get('g-recaptcha-response')); $form = $this->createForm(MembershipRequestType::class, $membership); if ($form->handleRequest($request)->isSubmitted() && $form->isValid()) { $this->get('app.membership_request_handler')->handle($membership); return $this->redirectToRoute('app_membership_donate'); } return $this->render('membership/register.html.twig', [ 'form' => $form->createView(), // ... ]); } }
  8. Janvier 2017 L’ancienne plateforme révèle ses limites Intégration de Mailjet

    à la plateforme src/Mailjet/ ├── ApiClient.php ├── ClientInterface.php ├── EmailTemplate.php ├── EmailTemplateFactory.php ├── Event/ │ ├── MailjetEvent.php │ └── MailjetEvents.php ├── EventSubscriber/ │ └── EmailPersisterEventSubscriber.php ├── Exception/ │ └── MailjetException.php ├── MailjetService.php ├── MailjetUtils.php ├── Message/ │ ├── AdherentAccountActivationMessage.php │ ├── AdherentAccountConfirmationMessage.php │ ├── ... │ └── TonMacronFriendMessage.php └── Transport/ ├── ApiTransport.php ├── RabbitMQTransport.php └── TransportInterface.php
  9. Janvier 2017 L’ancienne plateforme révèle ses limites Intégration de Mailjet

    à la plateforme class MailjetService { // ... public function sendMessage(MailjetMessage $message): bool { $email = $this->factory->createFromMailjetMessage($message); try { $this->dispatcher->dispatch(MailjetEvents::DELIVERY_MESSAGE, new MailjetEvent($message, $email)); $this->transport->sendTemplateEmail($email); $this->dispatcher->dispatch(MailjetEvents::DELIVERY_SUCCESS, new MailjetEvent($message, $email)); } catch (MailjetException $e) { $this->dispatcher->dispatch(MailjetEvents::DELIVERY_ERROR, new MailjetEvent($message, $email, $e)); return false; } return true; } }
  10. Janvier 2017 L’ancienne plateforme révèle ses limites Intégration de Mailjet

    à la plateforme class MembershipRequestHandler { // ... public function handle(MembershipRequest $membershipRequest) { $adherent = $this->adherentFactory->createFromMembershipRequest($membershipRequest); $token = AdherentActivationToken::generate($adherent); $this->manager->persist($adherent); $this->manager->persist($token); $this->manager->flush(); $activationUrl = $this->generateMembershipActivationUrl($adherent, $token); $this->mailjet->sendMessage(AdherentAccountActivationMessage::createFromAdherent($adherent, $activationUrl)); $event = new AdherentAccountWasCreatedEvent($adherent); $this->dispatcher->dispatch(AdherentEvents::REGISTRATION_COMPLETED, $event); } }
  11. Février 2017 La campagne bat son plein ! Intensification des

    développements •  Gestion des comités et des événements •  Geocodage des comités, événements et adhérents •  Tests unitaires et fonctionnels •  Cache applicatif avec Redis
  12. Février 2017 La campagne bat son plein ! Gestion des

    comités class CommitteeManagementAuthority { // ... public function followCommittee(Adherent $adherent, Committee $committee) { $this->manager->followCommittee($adherent, $committee); if (!$hosts = $this->manager->getCommitteeHosts($committee)->toArray()) { return; } $this->mailjet->sendMessage(CommitteeNewFollowerMessage::create( $committee, $hosts, $adherent, $this->urlGenerator->getUrl('app_commitee_manager_list_members', $committee) )); } }
  13. Février 2017 La campagne bat son plein ! Gestion des

    comités /** @Route("/comites/{uuid}/{slug}", requirements={"uuid": "%pattern_uuid%"}) */ class CommitteeController extends Controller { /** * @Route("/rejoindre", name="app_committee_follow", condition="request.request.has('token')") * @Method("POST") * @Security("is_granted('FOLLOW_COMMITTEE', committee)") */ public function followCommitteeAction(Request $request, Committee $committee): Response { if (!$this->isCsrfTokenValid('committee.follow', $request->request->get('token'))) { throw $this->createAccessDeniedException('...'); } $this->get('app.committee.authority')->followCommittee($this->getUser(), $committee); return new JsonResponse(/* ... */); } }
  14. Février 2017 La campagne bat son plein ! Gestion des

    comités src/Committee/Voter ├── AbstractCommitteeVoter.php ├── CreateCommitteeVoter.php ├── FollowCommitteeVoter.php ├── HostCommitteeVoter.php ├── ShowCommitteeVoter.php └── SuperviseCommitteeVoter.php
  15. Février 2017 La campagne bat son plein ! L’annotation @Entity

    class MembershipController extends Controller { /** * @Route( * path="/inscription/finaliser/{adherent_uuid}/{activation_token}", * name="app_membership_activate", * requirements={ * "adherent_uuid": "%pattern_uuid%", * "activation_token": "%pattern_sha1%" * } * ) * @Entity("adherent", expr="repository.findOneByUuid(adherent_uuid)") * @Entity("token", expr="repository.findByToken(activation_token)") */ public function activateAction(Adherent $adherent, AdherentActivationToken $token) { ... } }
  16. Février 2017 La campagne bat son plein ! Geocodage d’adresses

    avec Google Maps class Geocoder implements GeocoderInterface { // ... public function geocode(string $address): Coordinates { try { $addresses = $this->geocoder->geocode($address); } catch (\Exception $exception) { throw GeocodingException::create($address, $exception); } if (!count($addresses)) { throw GeocodingException::create($address); } $geocoded = $addresses->first(); return new Coordinates($geocoded->getLatitude(), $geocoded->getLongitude()); } }
  17. Février 2017 La campagne bat son plein ! Geocodage d’adresses

    avec Google Maps class EntityAddressGeocodingSubscriber implements EventSubscriberInterface { // ... public function onCommitteeCreated(CommitteeWasCreatedEvent $event) { $this->updateGeocodableEntity($event->getCommittee()); } private function updateGeocodableEntity(GeoPointInterface $geocodable) { if ($coordinates = $this->geocoder->geocode($geocodable->getGeocodableAddress())) { $geocodable->updateCoordinates($coordinates); $this->manager->flush(); } } }
  18. Février 2017 La campagne bat son plein ! Tests fonctionnels

    /** @group functional */ class CommitteeControllerTest extends MysqlWebTestCase { // ... public function testAnonymousUserIsNotAllowedToFollowCommittee() { $committeeUrl = sprintf('/comites/%s/%s', Data::COMMITTEE_3_UUID, 'en-marche-clichy'); $crawler = $this->client->request(Request::METHOD_GET, $committeeUrl); $this->assertResponseStatusCode(Response::HTTP_OK, $this->client->getResponse()); $this->assertFalse($this->seeFollowLink($crawler)); $this->assertFalse($this->seeUnfollowLink($crawler)); $this->assertTrue($this->seeRegisterLink($crawler)); } }
  19. Mars 2017 Stabilisation de la plateforme Optimisation du référencement <script

    type="application/ld+json"> { "@context": "http://schema.org", "@type": "Organization", "name": "ClichyEnMarche", "url": "http://en-marche.fr/comites/8f2bbd50-0f53-5c42-854e-4b8b2765afff/clichyenmarche", "image": "http://en-marche.fr/images/default_sharer.jpg", "description": "[email protected]", "location": { "@type": "PostalAddress", "streetAddress": "60 boulevard Jean Jaurès", "addressLocality": "Clichy", "postalCode": "92110", "addressCountry": "FR" } } </script>
  20. Mars 2017 Stabilisation de la plateforme Feature flags # app/config/parameters.yml

    parameters: # Staging configuration env(ENABLE_CANARY): 1 # Production configuration env(ENABLE_CANARY): 0
  21. Mars 2017 Stabilisation de la plateforme Feature flags class PageController

    extends Controller { // ... public function ellesMarchentAction() { if (!((bool) $this->getParameter('enable_canary'))) { throw $this->createNotFoundException(); } return $this->render('page/elles-marchent.html.twig'); } }
  22. framework: workflows: ton_macron_invitation: type: state_machine supports: [AppBundle\TonMacron\InvitationProcessor] places: - !php/const:AppBundle\TonMacron\InvitationProcessor::STATE_NEEDS_FRIEND_INFO

    - !php/const:AppBundle\TonMacron\InvitationProcessor::STATE_NEEDS_FRIEND_PROJECT - ... transitions: !php/const:AppBundle\TonMacron\InvitationProcessor::TRANSITION_FILL_INFO: from: - !php/const:AppBundle\TonMacron\InvitationProcessor::STATE_NEEDS_FRIEND_INFO to: !php/const:AppBundle\TonMacron\InvitationProcessor::STATE_NEEDS_FRIEND_PROJECT !php/const:AppBundle\TonMacron\InvitationProcessor::TRANSITION_FILL_PROJECT: # ... Avril 2017 En route vers le premier tour ! Générateur Ton Macron
  23. final class InvitationProcessorHandler { // ... public function __construct(..., StateMachine

    $stateMachine) { // ... $this->stateMachine = $stateMachine; } public function process(InvitationProcessor $processor): ?TonMacronFriendInvitation { if ($this->stateMachine->can($processor, InvitationProcessor::TRANSITION_SEND)) { // ... $this->stateMachine->apply($processor, InvitationProcessor::TRANSITION_SEND); // ... } $this->stateMachine->apply($processor, $this->getCurrentTransition($processor)); if ($this->stateMachine->can($processor, InvitationProcessor::TRANSITION_SEND)) { // ... } } }
  24. Avril 2017 En route vers le premier tour ! Respect

    du code électoral class ArticleController extends Controller { /** * @Route( * "/articles/{category}/{page}", * requirements={"category"="\w+", "page"="\d+"}, * defaults={"page"=1, "_enable_campaign_silence"=true} * ) */ public function actualitesAction(...): Response { ... } }
  25. Avril 2017 En route vers le premier tour ! Respect

    du code électoral class CampaignSilenceSubscriber implements EventSubscriberInterface { // ... public function onKernelController(FilterControllerEvent $event) { // ... $expired = $this->processor->isCampaignExpired($request); $request->attributes->set('_campaign_expired', $expired); if (!$expired || $request->attributes->get('_enable_campaign_silence', false)) { return; } $event->setController(function () { return new Response($this->twig->render('campaign_silent.html.twig')); }); } }
  26. Avril 2017 En route vers le premier tour ! Respect

    du code électoral # app/config/config_prod.yml services: app.mailjet.transport.transactional: class: 'AppBundle\Mailjet\Transport\MailjetNullTransport' arguments: ['@?logger'] public: false app.mailjet.transport.campaign: class: 'AppBundle\Mailjet\Transport\MailjetNullTransport' arguments: ['@?logger'] public: false
  27. Mai 2017 L’élection Bloquer les tentatives de DDOS # ...

    http { # ... server { # ... # Block WordPress Pingback DDoS attacks if ($http_user_agent ~* "WordPress") { return 403; } # ... } }
  28. Mai 2017 L’élection Profiter du cache HTTP class AssetsController extends

    Controller { /** * @Route("/assets/{path}") * @Method("GET") * @Cache(maxage=900, smaxage=900) */ public function assetAction(string $path, Request $request) { // ... } }