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

Building an Open-Source Campaign Platform for t...

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
  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
  3. Titouan Galopin Enrolled in the En-Marche movement Initiated the Symfony

    migration Full-Stack Developer Setup the new technical stack @titouangalopin on Twitter
  4. +

  5. 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.
  6. 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); } } }
  7. 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.
  8. 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.
  9. 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.
  10. 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
  11. 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) ); } } }
  12. 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); } }
  13. 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
  14. Service Layer Custom voters src/Committee/Voter ├─ AbstractCommitteeVoter.php ├─ CreateCommitteeVoter.php ├─

    FollowCommitteeVoter.php ├─ HostCommitteeVoter.php ├─ ShowCommitteeVoter.php └─ SuperviseCommitteeVoter.php
  15. 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
  16. 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.
  17. 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))))'; } }
  18. 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') ; } }
  19. /** * @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
  20. <script type="application/ld+json"> { "@context": "http://schema.org", "@type": "WebSite", "url": "http://en-marche.fr", "name":

    "En Marche !", "image": "http://en-marche.fr/images/default_sharer.jpg", "description": "Pour ceux qui ... l'Europe.", "funder": { "@type": "Person", "givenName": "Emmanuel", "familyName": "Macron", "jobTitle": "President of France" } } </script> Schema.org JSON LD Metadata in HTML code
  21. class PageController extends Controller { // ... public function ellesMarchentAction()

    { if (!$this->getParameter('enable_canary'))) { throw $this->createNotFoundException(); } return $this->render('... '); } } Feature Flags
  22. 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(), ])); } }
  23. // ... 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 (...) { ... } } }
  24. 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
  25. 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
  26. {% if not app.request.attributes.get('_campaign_expired') and not user_is_adherent %} <a href="{{

    hwi_oauth_login_url('auth') }}" class="btn b__nudge--right-nano"> Connexion </a> <a href="{{ auth_register_url }}" class="btn b__nudge--right"> Inscription </a> {% endif %} Disabling links in Twig
  27. 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
  28. 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')); }); } }
  29. # 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
  30. # ... http { # ... server { # ...

    # Block WP Pingback DDoS attacks if ($http_user_agent ~* "WordPress") { return 403; } # ... } } WordPress pingback DDoS attacks
  31. class AssetsController extends Controller { /** * @Route("/assets/{path}") * @Method("GET")

    * @Cache(maxage=900, smaxage=900) */ public function assetAction(string $path, ...) { // ... } } Http caching for static assets