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

Building an Open-Source Campaign Platform for the new President of France

Hugo Hamon
November 17, 2017

Building an Open-Source Campaign Platform for the new President of France

This case study will take you to the technical backdrop of Emmanuel Macron's victorious campaign in the French presidential elections of 2017. We will reveal how the En-Marche and SensioLabs teams worked closely together to develop a collaborative, Open-Source and citizen platform with PHP 7 and Symfony to structure the movement. In addition to the technical and political issues involved in this project, we'll present solutions such as continuous deployment with Docker under Kubernetes, continuous integration with Circle CI, SCRUM and Kanban project management, use of Rabbitmq, Symfony 3 new features, and more. We'll demonstrate the importance of choosing these solutions to meet the agility and scalability needs of the highlights of the campaign.

Hugo Hamon

November 17, 2017
Tweet

More Decks by Hugo Hamon

Other Decks in Technology

Transcript

  1. Building an Open-Source
    Campaign Platform
    for the President of
    France .
    SymfonyCon 2017 / Nov. 17th / Cluj / Romania
    Hugo Hamon

    View Slide

  2. Hugo Hamon
    Senior Software Developer
    15 years of PHP experience
    10 years of Symfony experience
    Conferences speaker
    @hhamon on social networks
    Books (co) author

    View Slide

  3. Titouan Galopin
    Enrolled in the En-Marche movement
    Initiated the Symfony migration
    Full-Stack Developer
    Setup the new technical stack
    @titouangalopin on Twitter

    View Slide

  4. Innovative
    e-learning solution
    targeting Symfony teams.
    https://university.sensiolabs.com

    View Slide

  5. The
    Context

    View Slide

  6. Why is the
    Presidential election
    such a challenging
    environment?

    View Slide

  7. Why building a
    Citizen Collaborative
    Platform?

    View Slide

  8. Accept donations
    Register adherents
    Facilitate communication
    Structure movement

    View Slide

  9. April
    2016

    View Slide

  10. View Slide

  11. Summer
    2016

    View Slide

  12. +

    View Slide

  13. View Slide

  14. October
    2016

    View Slide

  15. View Slide

  16. https://github.com/enmarche/en-marche.fr

    View Slide

  17. https://en-marche.fr

    View Slide

  18. Response Web Design
    Full featured Admin area
    Users management
    Fined grained permissions
    Http caching
    Images lazy loading
    Content Management
    Unit & functional tests
    Mass emails management
    Maps & address geocoding
    Social networks integration
    Procurations management
    Media management
    Field actions reports
    Legislatives elections support
    Logging
    Search engine
    Etc.

    View Slide

  19. End of Year
    2016

    View Slide

  20. Emmanuel Macron
    formalizes his
    candidacy for the
    presidential election.

    View Slide

  21. Symfony 3.2 also
    comes out!

    View Slide

  22. Coincidence?
    J

    View Slide

  23. View Slide

  24. View Slide

  25. # app/config/parameters.yml
    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

    View Slide

  26. View Slide

  27. class AssetsController extends Controller
    {
    /**
    * @Route("/assets/{path}", name="asset_url")
    * @Cache(maxage=900, smaxage=900)
    */
    public function assetAction(Request $request, string $path): Response
    {
    // ...
    $glide = $this->get('app.glide');
    $glide->setResponseFactory(new SymfonyResponseFactory($request));
    try {
    return $glide->getImageResponse($path, $request->query->all());
    } catch (FileNotFoundException $e) {
    throw $this->createNotFoundException('', $e);
    }
    }
    }

    View Slide

  28. January
    2017

    View Slide

  29. View Slide

  30. Week n
    People at En-Marche write the User Stories and
    introduce them at SensioLabs on Thursday.
    Week n+1
    Poker planning session on Tuesday with the
    SensioLabs team.
    Week n+2
    New coding sprint starts on Monday. Demo
    session is on Friday afternoon.

    View Slide

  31. trait EntityIdentityTrait
    {
    /**
    * @ORM\Id
    * @ORM\Column(type="integer")
    * @ORM\GeneratedValue
    */
    private $id;
    /**
    * @ORM\Column(type="uuid")
    * @var \Ramsey\Uuid\UuidInterface
    */
    private $uuid;
    // ... + getters
    }
    Custom UUIDs for
    Doctrine entities.

    View Slide

  32. class MembershipController extends Controller
    {
    // ...
    public function registerAction(Request $request): Response
    {
    $membership = MembershipRequest::createWithCaptcha(...);
    $form = $this
    ->createForm(MembershipRequestType::class, $membership)
    ->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
    $this->get('app.membership')->handle($membership);
    return $this->redirectToRoute('app_membership_donate');
    }
    return $this->render('...', [
    'form' => $form->createView(),
    // ...
    ]);
    }
    }
    Thin & dumb
    controllers.

    View Slide

  33. 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

    View Slide

  34. class MailjetService
    {
    // ...
    public function sendMessage(MailjetMessage $message): void
    {
    $email = $this->factory->createFromMailjetMessage($message);
    $event = new MailjetEvent($message, $email);
    try {
    $this->dispatch(MailjetEvents::DELIVERY_MESSAGE, $event);
    $this->transport->sendTemplateEmail($email);
    $this->dispatch(MailjetEvents::DELIVERY_SUCCESS, $event);
    } catch (MailjetException $e) {
    $this->dispatch(
    MailjetEvents::DELIVERY_ERROR,
    new MailjetEvent($message, $email, $e)
    );
    }
    }
    }

    View Slide

  35. class MembershipRequestHandler
    {
    // ...
    public function handle(MembershipRequest $membershipRequest)
    {
    $adherent = $this->factory->createFromMembershipRequest($membershipRequest);
    $token = AdherentActivationToken::generate($adherent);
    $this->manager->persist($adherent);
    $this->manager->persist($token);
    $this->manager->flush();
    $message = AdherentAccountActivationMessage::createFromAdherent(
    $adherent,
    $this->generateMembershipActivationUrl($adherent, $token)
    );
    $this->mailjet->sendMessage($message);
    $e = new AdherentAccountWasCreatedEvent($adherent);
    $this->dispatch(..., $e);
    }
    }

    View Slide

  36. February
    2017

    View Slide

  37. View Slide

  38. class CommitteeManagementAuthority
    {
    // ...
    function followCommittee(Adherent $adherent, Committee $committee): void
    {
    $this->manager->followCommittee($adherent, $committee);
    if (!$hosts = $this->manager->getCommitteeHosts($committee)) {
    return;
    }
    $this->mailjet->sendMessage(CommitteeNewFollowerMessage::create(
    $committee,
    $hosts,
    $adherent,
    $this->urlGenerator->getUrl('...', $committee)
    ));
    }
    } Service
    Layer

    View Slide

  39. Service
    Layer
    Custom voters
    src/Committee/Voter
    ├─ AbstractCommitteeVoter.php
    ├─ CreateCommitteeVoter.php
    ├─ FollowCommitteeVoter.php
    ├─ HostCommitteeVoter.php
    ├─ ShowCommitteeVoter.php
    └─ SuperviseCommitteeVoter.php

    View Slide

  40. 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 (!$geo = $addresses->first()) {
    throw GeocodingException::create($address);
    }
    return new Coordinates(
    $geo->getLatitude(),
    $geo->getLongitude()
    );
    }
    }
    Geocoding addresses
    Based on Google Maps

    View Slide

  41. class EntityAddressGeocodingSubscriber implements EventSubscriberInterface
    {
    // ...
    public function onCommitteeCreated(CommitteeWasCreatedEvent $event)
    {
    $this->updateGeocodableEntity($event->getCommittee());
    }
    private function updateGeocodableEntity(GeoPointInterface $geocodable)
    {
    if ($coords = $this->geocode($geocodable->getGeocodableAddress())) {
    $geocodable->updateCoordinates($coords);
    $this->manager->flush();
    }
    }
    }
    Doctrine listener to update coordinates of any
    Geocodable entity when it’s saved to the database.

    View Slide

  42. View Slide

  43. View Slide

  44. trait NearbyTrait
    {
    public function getNearbyExpression(): string
    {
    return '(6371 * acos(cos(radians(:latitude))
    * cos(radians(n.postAddress.latitude))
    * cos(radians(n.postAddress.longitude) -
    radians(:longitude)) + sin(radians(:latitude)) *
    sin(radians(n.postAddress.latitude))))';
    }
    }

    View Slide

  45. trait NearbyTrait
    {
    function createNearbyQueryBuilder(Coordinates $coordinates, bool $hidden):
    {
    $hidden = $hidden ? 'hidden' : '';
    return $this
    ->createQueryBuilder('n')
    ->addSelect($this->getNearbyExpression().' as '.$hidden.' dst_between')
    ->setParameter('latitude', $coordinates->getLatitude())
    ->setParameter('longitude', $coordinates->getLongitude())
    ->where('n.postAddress.latitude IS NOT NULL')
    ->andWhere('n.postAddress.longitude IS NOT NULL')
    ->orderBy('dst_between', 'asc')
    ;
    }
    }

    View Slide

  46. /**
    * @group functional
    */
    class CommitteeControllerTest extends MysqlWebTestCase
    {
    // ...
    function testAnonymousUserIsNotAllowedToFollowCommittee()
    {
    $crawler = $this->client->request('GET', ...);
    $this->assertResponseStatusCode(200, ...);
    $this->assertFalse($this->seeFollowLink($crawler));
    $this->assertFalse($this->seeUnfollowLink($crawler));
    $this->assertTrue($this->seeRegisterLink($crawler));
    }
    }
    Functional Testing
    PHPUnit

    View Slide

  47. Continuous Integration
    Pull-Requests
    SensioLabsInsight
    Style CI
    Circle CI

    View Slide

  48. March
    2017

    View Slide

  49. Program announced!
    +6,000 users live
    +300,000 daily

    View Slide

  50. <br/>{<br/>"@context": "http://schema.org",<br/>"@type": "WebSite",<br/>"url": "http://en-marche.fr",<br/>"name": "En Marche !",<br/>"image": "http://en-marche.fr/images/default_sharer.jpg",<br/>"description": "Pour ceux qui ... l'Europe.",<br/>"funder": {<br/>"@type": "Person",<br/>"givenName": "Emmanuel",<br/>"familyName": "Macron",<br/>"jobTitle": "President of France"<br/>}<br/>}<br/>
    Schema.org
    JSON LD
    Metadata in HTML code

    View Slide

  51. Optimizing natural indexing in
    search engines.

    View Slide

  52. # app/config/parameters.yml
    parameters:
    # Staging configuration
    env(ENABLE_CANARY): 1
    # Production configuration
    env(ENABLE_CANARY): 0
    Feature Flags
    Env vars

    View Slide

  53. class PageController extends Controller
    {
    // ...
    public function ellesMarchentAction()
    {
    if (!$this->getParameter('enable_canary'))) {
    throw $this->createNotFoundException();
    }
    return $this->render('... ');
    }
    }
    Feature
    Flags

    View Slide

  54. Procuration System

    View Slide

  55. Asynchronous Tasks
    use AppBundle\Mailer\EmailTemplate;
    use OldSound\RabbitMqBundle\RabbitMq\Producer;
    class MailerProducer extends Producer implements MailerProducerInterface
    {
    public function scheduleEmail(EmailTemplate $email): void
    {
    $this->publish(json_encode([
    'uuid' => (string) $email->getUuid(),
    ]));
    }
    }

    View Slide

  56. // ...
    abstract class AbstractMailerConsumer extends AbstractConsumer
    {
    // ...
    protected function doExecute(array $data): int
    {
    try {
    if (!$message = $this->repository->findOneByUuid($data['uuid'])) {
    $this->logger->error('Email not found', $data);
    return self::MSG_ACK;
    }
    if ($delivered = $this->sendEmail($message->getRequestPayloadJson())) {
    $this->getEmailRepository()->setDelivered($message, $delivered);
    }
    return $delivered ? self::MSG_ACK : self::MSG_REJECT_REQUEUE;
    } catch (...) { ... }
    }
    }

    View Slide

  57. April
    2017

    View Slide

  58. Official regulations require
    candidates to stop
    proactive propaganda 24h
    before each ballot.

    View Slide

  59. Notifications
    Committees & events
    Make the website read-only!
    Contents publication
    Emails

    View Slide

  60. Who’s trully
    concerned?

    View Slide

  61. https://commons.wikimedia.org/wiki/File%3AMapadefrancia.svg

    View Slide

  62. UTC -3
    Guyana
    Saint Pierre and Miquelon
    UTC -4
    St. Martin
    St. Barthelemy
    Guadeloupe
    Martinique
    UTC -8
    Clipperton
    UTC -9 / UTC -9,5 / UTC -10
    French Polynesia
    https://commons.wikimedia.org/wiki/File%3AMapadefrancia.svg

    View Slide

  63. UTC +3
    Mayotte
    Europa Island
    https://commons.wikimedia.org/wiki/File%3AMapadefrancia.svg
    UTC +4
    Crozet Islands
    Reunion
    Glorious Islands
    Tromelin Island
    Juan de Nova
    UTC +5
    St. Paul and
    Amsterdam
    Kerguelen Islands
    UTC +10
    Adelie Land
    UTC +10
    New Caledonia
    UTC +12
    Wallis and Futuna

    View Slide

  64. How to make the
    platform fullfil the
    official regulations?

    View Slide

  65. {%
    if not app.request.attributes.get('_campaign_expired')
    and not user_is_adherent
    %}
    class="btn b__nudge--right-nano">
    Connexion

    class="btn b__nudge--right">
    Inscription

    {% endif %}
    Disabling links in Twig

    View Slide

  66. class ArticleController extends Controller
    {
    /**
    * @Route(
    * "/articles/{category}/{page}",
    * requirements={"category"="\w+", "page"="\d+"},
    * defaults={ "_enable_campaign_silence": true }
    * )
    */
    public function actualitesAction(...): Response
    { ... }
    }
    Marking a mutable action

    View Slide

  67. 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'));
    });
    }
    }

    View Slide

  68. # 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

    View Slide

  69. April 23rd
    1st Ballot

    View Slide

  70. ~500,000 connections
    to the website.

    View Slide

  71. May
    2017

    View Slide

  72. # ...
    http {
    # ...
    server {
    # ...
    # Block WP Pingback DDoS attacks
    if ($http_user_agent ~* "WordPress") {
    return 403;
    }
    # ...
    }
    }
    WordPress
    pingback DDoS
    attacks

    View Slide

  73. class AssetsController extends Controller
    {
    /**
    * @Route("/assets/{path}")
    * @Method("GET")
    * @Cache(maxage=900, smaxage=900)
    */
    public function assetAction(string $path, ...)
    {
    // ...
    }
    }
    Http caching for
    static assets

    View Slide

  74. May 7th
    2nd Ballot

    View Slide

  75. ~650,000 connections
    to the website.

    View Slide

  76. Wrap Up

    View Slide

  77. Thank you!
    https://www.flickr.com/photos/enmarchefr/31534490113/
    @titouangalopin
    @hhamon
    @sensiolabs

    View Slide