]> Raphaël G. Git Repositories - airbundle/blobdiff - Controller/DefaultController.php
Add dance and subscription fields
[airbundle] / Controller / DefaultController.php
index e55d6e8398cb48baf38145cc2877b756ba20c8b5..9554557c94355706684576dc863c56c0f15d4432 100644 (file)
 namespace Rapsys\AirBundle\Controller;
 
 use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
 use Symfony\Component\Form\FormError;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
-use Symfony\Component\Mailer\MailerInterface;
 use Symfony\Component\Mime\Address;
-use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
 
-use Rapsys\AirBundle\Entity\Civility;
+use Rapsys\AirBundle\Entity\Dance;
 use Rapsys\AirBundle\Entity\Location;
 use Rapsys\AirBundle\Entity\Session;
 use Rapsys\AirBundle\Entity\Snippet;
 use Rapsys\AirBundle\Entity\User;
-use Rapsys\AirBundle\Pdf\DisputePdf;
+use Rapsys\AirBundle\Token\AnonymousToken;
 
 /**
  * {@inheritdoc}
@@ -41,7 +43,7 @@ class DefaultController extends AbstractController {
         */
        public function about(Request $request): Response {
                //Set page
-               $this->context['title'] = $this->translator->trans('About');
+               $this->context['title']['page'] = $this->translator->trans('About');
 
                //Set description
                $this->context['description'] = $this->translator->trans('Libre Air about');
@@ -68,13 +70,12 @@ class DefaultController extends AbstractController {
         * @desc Send a contact mail to configured contact
         *
         * @param Request $request The request instance
-        * @param MailerInterface $mailer The mailer instance
         *
         * @return Response The rendered view or redirection
         */
-       public function contact(Request $request, MailerInterface $mailer): Response {
+       public function contact(Request $request): Response {
                //Set page
-               $this->context['title'] = $this->translator->trans('Contact');
+               $this->context['title']['page'] = $this->translator->trans('Contact');
 
                //Set description
                $this->context['description'] = $this->translator->trans('Contact Libre Air');
@@ -88,9 +89,21 @@ class DefaultController extends AbstractController {
                        $this->translator->trans('calendar')
                ];
 
+               //Set data
+               $data = [];
+
+               //With user
+               if ($user = $this->security->getUser()) {
+                       //Set data
+                       $data = [
+                               'name' => $user->getRecipientName(),
+                               'mail' => $user->getMail()
+                       ];
+               }
+
                //Create the form according to the FormType created previously.
                //And give the proper parameters
-               $form = $this->createForm('Rapsys\AirBundle\Form\ContactType', null, [
+               $form = $this->factory->create('Rapsys\AirBundle\Form\ContactType', $data, [
                        'action' => $this->generateUrl('rapsys_air_contact'),
                        'method' => 'POST'
                ]);
@@ -99,7 +112,7 @@ class DefaultController extends AbstractController {
                        // Refill the fields in case the form is not valid.
                        $form->handleRequest($request);
 
-                       if ($form->isValid()) {
+                       if ($form->isSubmitted() && $form->isValid()) {
                                //Get data
                                $data = $form->getData();
 
@@ -108,7 +121,7 @@ class DefaultController extends AbstractController {
                                        //Set sender
                                        ->from(new Address($data['mail'], $data['name']))
                                        //Set recipient
-                                       ->to(new Address($this->context['contact']['mail'], $this->context['contact']['title']))
+                                       ->to(new Address($this->context['contact']['address'], $this->context['contact']['name']))
                                        //Set subject
                                        ->subject($data['subject'])
 
@@ -128,7 +141,7 @@ class DefaultController extends AbstractController {
                                //XXX: mail delivery may silently fail
                                try {
                                        //Send message
-                                       $mailer->send($message);
+                                       $this->mailer->send($message);
 
                                        //Redirect on the same route with sent=1 to cleanup form
                                        return $this->redirectToRoute($request->get('_route'), ['sent' => 1]+$request->get('_route_params'));
@@ -136,10 +149,10 @@ class DefaultController extends AbstractController {
                                } catch(TransportExceptionInterface $e) {
                                        if ($message = $e->getMessage()) {
                                                //Add error message mail unreachable
-                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%: %message%', ['%mail%' => $this->context['contact']['mail'], '%message%' => $this->translator->trans($message)])));
+                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%: %message%', ['%mail%' => $this->context['contact']['address'], '%message%' => $this->translator->trans($message)])));
                                        } else {
                                                //Add error message mail unreachable
-                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%', ['%mail%' => $this->context['contact']['mail']])));
+                                               $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%', ['%mail%' => $this->context['contact']['address']])));
                                        }
                                }
                        }
@@ -150,189 +163,120 @@ class DefaultController extends AbstractController {
        }
 
        /**
-        * The dispute page
+        * The index page
         *
-        * @desc Generate a dispute document
+        * Display session calendar
         *
         * @param Request $request The request instance
-        * @param MailerInterface $mailer The mailer instance
-        *
-        * @return Response The rendered view or redirection
+        * @return Response The rendered view
         */
-       public function dispute(Request $request, MailerInterface $mailer): Response {
-               //Prevent non-guest to access here
-               $this->denyAccessUnlessGranted('ROLE_USER', null, $this->translator->trans('Unable to access this page without role %role%!', ['%role%' => $this->translator->trans('User')]));
+       public function index(Request $request): Response {
+               //Add cities
+               $this->context['cities'] = $this->doctrine->getRepository(Location::class)->findCitiesAsArray($this->period);
 
-               //Set page
-               $this->context['title'] = $this->translator->trans('Dispute');
+               //Add calendar
+               $this->context['calendar'] = $this->doctrine->getRepository(Session::class)->findAllByPeriodAsCalendarArray($this->period, !$this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED'));
 
-               //Set description
-               $this->context['description'] = $this->translator->trans('Libre Air dispute');
+               //Add dances
+               $this->context['dances'] = $this->doctrine->getRepository(Dance::class)->findNamesAsArray();
 
-               //Set keywords
-               $this->context['keywords'] = [
-                       $this->translator->trans('dispute'),
-                       $this->translator->trans('Libre Air'),
-                       $this->translator->trans('outdoor'),
-                       $this->translator->trans('Argentine Tango'),
-                       $this->translator->trans('calendar')
-               ];
+               //Set modified
+               $this->modified = max(array_map(function ($v) { return $v['modified']; }, array_merge($this->context['calendar'], $this->context['cities'], $this->context['dances'])));
 
-               //Create the form according to the FormType created previously.
-               //And give the proper parameters
-               $form = $this->createForm('Rapsys\AirBundle\Form\DisputeType', ['court' => 'Paris', 'abstract' => 'Pour constater cette prétendue infraction, les agents verbalisateurs ont pénétré dans un jardin privatif, sans visibilité depuis la voie publique, situé derrière un batiment privé, pour ce faire ils ont franchi au moins un grillage de chantier ou des potteaux métalliques séparant le terrain privé de la voie publique de l\'autre côté du batiment.'], [
-                       'action' => $this->generateUrl('rapsys_air_dispute'),
-                       'method' => 'POST'
-               ]);
+               //Create response
+               $response = new Response();
 
-               if ($request->isMethod('POST')) {
-                       // Refill the fields in case the form is not valid.
-                       $form->handleRequest($request);
+               //With logged user
+               if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
+                       //Set last modified
+                       $response->setLastModified(new \DateTime('-1 year'));
 
-                       if ($form->isValid()) {
-                               //Get data
-                               $data = $form->getData();
+                       //Set as private
+                       $response->setPrivate();
+               //Without logged user
+               } else {
+                       //Set etag
+                       //XXX: only for public to force revalidation by last modified
+                       $response->setEtag(md5(serialize(array_merge($this->context['calendar'], $this->context['cities'], $this->context['dances']))));
 
-                               //Gathering offense
-                               if (!empty($data['offense']) && $data['offense'] == 'gathering') {
-                                       //Add gathering
-                                       $output = DisputePdf::genGathering($data['court'], $data['notice'], $data['agent'], $data['service'], $data['abstract'], $this->translator->trans($this->getUser()->getCivility()->getTitle()), $this->getUser()->getForename(), $this->getUser()->getSurname());
-                               //Traffic offense
-                               } elseif (!empty($data['offense'] && $data['offense'] == 'traffic')) {
-                                       //Add traffic
-                                       $output = DisputePdf::genTraffic($data['court'], $data['notice'], $data['agent'], $data['service'], $data['abstract'], $this->translator->trans($this->getUser()->getCivility()->getTitle()), $this->getUser()->getForename(), $this->getUser()->getSurname());
-                               //Unsupported offense
-                               } else {
-                                       header('Content-Type: text/plain');
-                                       die('TODO');
-                                       exit;
-                               }
+                       //Set last modified
+                       $response->setLastModified($this->modified);
 
-                               //Send common headers
-                               header('Content-Type: application/pdf');
-
-                               //Send remaining headers
-                               header('Cache-Control: private, max-age=0, must-revalidate');
-                               header('Pragma: public');
-
-                               //Send content-length
-                               header('Content-Length: '.strlen($output));
-
-                               //Display the pdf
-                               echo $output;
-
-                               //Die for now
-                               exit;
-
-#                              //Create message
-#                              $message = (new TemplatedEmail())
-#                                      //Set sender
-#                                      ->from(new Address($data['mail'], $data['name']))
-#                                      //Set recipient
-#                                      //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
-#                                      ->to(new Address($this->config['contact']['mail'], $this->config['contact']['title']))
-#                                      //Set subject
-#                                      ->subject($data['subject'])
-#
-#                                      //Set path to twig templates
-#                                      ->htmlTemplate('@RapsysAir/mail/contact.html.twig')
-#                                      ->textTemplate('@RapsysAir/mail/contact.text.twig')
-#
-#                                      //Set context
-#                                      ->context(
-#                                              [
-#                                                      'subject' => $data['subject'],
-#                                                      'message' => strip_tags($data['message']),
-#                                              ]+$this->context
-#                                      );
-#
-#                              //Try sending message
-#                              //XXX: mail delivery may silently fail
-#                              try {
-#                                      //Send message
-#                                      $mailer->send($message);
-#
-#                                      //Redirect on the same route with sent=1 to cleanup form
-#                                      return $this->redirectToRoute($request->get('_route'), ['sent' => 1]+$request->get('_route_params'));
-#                              //Catch obvious transport exception
-#                              } catch(TransportExceptionInterface $e) {
-#                                      if ($message = $e->getMessage()) {
-#                                              //Add error message mail unreachable
-#                                              $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%: %message%', ['%mail%' => $this->config['contact']['mail'], '%message%' => $this->translator->trans($message)])));
-#                                      } else {
-#                                              //Add error message mail unreachable
-#                                              $form->get('mail')->addError(new FormError($this->translator->trans('Unable to contact: %mail%', ['%mail%' => $this->config['contact']['mail']])));
-#                                      }
-#                              }
+                       //Set as public
+                       $response->setPublic();
+
+                       //Without role and modification
+                       if ($response->isNotModified($request)) {
+                               //Return 304 response
+                               return $response;
                        }
                }
 
-               //Render template
-               return $this->render('@RapsysAir/default/dispute.html.twig', ['form' => $form->createView(), 'sent' => $request->query->get('sent', 0)]+$this->context);
-       }
-
-       /**
-        * The index page
-        *
-        * @desc Display all granted sessions with an application or login form
-        *
-        * @param Request $request The request instance
-        * @return Response The rendered view
-        */
-       public function index(Request $request): Response {
-               //Fetch doctrine
-               $doctrine = $this->getDoctrine();
+               //With cities
+               if (!empty($this->context['cities'])) {
+                       //Set locations
+                       $locations = [];
+
+                       //Iterate on each cities
+                       foreach($this->context['cities'] as $city) {
+                               //Iterate on each locations
+                               foreach($city['locations'] as $location) {
+                                       //Add location
+                                       $locations[$location['id']] = $location;
+                               }
+                       }
 
-               //Set page
-               $this->context['title'] = $this->translator->trans('Argentine Tango in Paris');
+                       //Add multi
+                       $this->context['multimap'] = $this->map->getMultiMap($this->translator->trans('Libre Air cities sector map'), $this->modified->getTimestamp(), $locations);
 
-               //Set description
-               $this->context['description'] = $this->translator->trans('Outdoor Argentine Tango session calendar in Paris');
+                       //Set cities
+                       $cities = array_map(function ($v) { return $v['in']; }, $this->context['cities']);
 
-               //Set keywords
-               $this->context['keywords'] = [
-                       $this->translator->trans('Argentine Tango'),
-                       $this->translator->trans('Paris'),
-                       $this->translator->trans('outdoor'),
-                       $this->translator->trans('calendar'),
-                       $this->translator->trans('Libre Air')
-               ];
+                       //Set dances
+                       $dances = array_map(function ($v) { return $v['name']; }, $this->context['dances']);
+               } else {
+                       //Set cities
+                       $cities = [];
 
-               //Set facebook type
-               //XXX: only valid for home page
-               $this->context['facebook']['metas']['og:type'] = 'website';
+                       //Set dances
+                       $dances = [];
+               }
 
-               //Compute period
-               $period = new \DatePeriod(
-                       //Start from first monday of week
-                       new \DateTime('Monday this week'),
-                       //Iterate on each day
-                       new \DateInterval('P1D'),
-                       //End with next sunday and 4 weeks
-                       new \DateTime(
-                               $this->isGranted('IS_AUTHENTICATED_REMEMBERED')?'Monday this week + 3 week':'Monday this week + 2 week'
+               //Set keywords
+               //TODO: use splice instead of that shit !!!
+               //TODO: handle smartly indoor and outdoor !!!
+               $this->context['keywords'] = array_values(
+                       array_merge(
+                               $dances,
+                               $cities,
+                               [
+                                       $this->translator->trans('indoor'),
+                                       $this->translator->trans('outdoor'),
+                                       $this->translator->trans('calendar'),
+                                       $this->translator->trans('Libre Air')
+                               ]
                        )
                );
 
-               //Fetch calendar
-               $calendar = $doctrine->getRepository(Session::class)->fetchCalendarByDatePeriod($this->translator, $period, null, $request->get('session'), !$this->isGranted('IS_AUTHENTICATED_REMEMBERED'), $request->getLocale());
+               //Get textual cities
+               $cities = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($cities, 0, -1))], array_slice($cities, -1)), 'strlen'));
 
-               //Fetch locations
-               //XXX: we want to display all active locations anyway
-               $locations = $doctrine->getRepository(Location::class)->findTranslatedSortedByPeriod($this->translator, $period);
+               //Get textual dances
+               $dances = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($dances, 0, -1))], array_slice($dances, -1)), 'strlen'));
 
-               //Render the view
-               return $this->render('@RapsysAir/default/index.html.twig', ['calendar' => $calendar, 'locations' => $locations]+$this->context);
+               //Set title
+               $this->context['title']['page'] = $this->translator->trans('%dances% %cities%', ['%dances%' => $dances, '%cities%' => $cities]);
+
+               //Set description
+               //TODO: handle french translation when city start with a A, change à in en !
+               $this->context['description'] = $this->translator->trans('%dances% indoor and outdoor calendar %cities%', ['%dances%' => $dances, '%cities%' => $cities]);
 
-               //Set Cache-Control must-revalidate directive
-               //TODO: add a javascript forced refresh after 1h ? or header refresh ?
-               #$response->setPublic(true);
-               #$response->setMaxAge(300);
-               #$response->mustRevalidate();
-               ##$response->setCache(['public' => true, 'max_age' => 300]);
+               //Set facebook type
+               //XXX: only valid for home page
+               $this->context['facebook']['metas']['og:type'] = 'website';
 
-               //Return the response
-               #return $response;
+               //Render the view
+               return $this->render('@RapsysAir/default/index.html.twig', $this->context, $response);
        }
 
        /**
@@ -345,7 +289,7 @@ class DefaultController extends AbstractController {
         */
        public function organizerRegulation(Request $request): Response {
                //Set page
-               $this->context['title'] = $this->translator->trans('Organizer regulation');
+               $this->context['title']['page'] = $this->translator->trans('Organizer regulation');
 
                //Set description
                $this->context['description'] = $this->translator->trans('Libre Air organizer regulation');
@@ -378,7 +322,7 @@ class DefaultController extends AbstractController {
         */
        public function termsOfService(Request $request): Response {
                //Set page
-               $this->context['title'] = $this->translator->trans('Terms of service');
+               $this->context['title']['page'] = $this->translator->trans('Terms of service');
 
                //Set description
                $this->context['description'] = $this->translator->trans('Libre Air terms of service');
@@ -411,7 +355,7 @@ class DefaultController extends AbstractController {
         */
        public function frequentlyAskedQuestions(Request $request): Response {
                //Set page
-               $this->context['title'] = $this->translator->trans('Frequently asked questions');
+               $this->context['title']['page'] = $this->translator->trans('Frequently asked questions');
 
                //Set description
                $this->context['description'] = $this->translator->trans('Libre Air frequently asked questions');
@@ -445,23 +389,26 @@ class DefaultController extends AbstractController {
         * @return Response The rendered view
         */
        public function userIndex(Request $request): Response {
-               //Fetch doctrine
-               $doctrine = $this->getDoctrine();
-
                //With admin role
-               if ($this->isGranted('ROLE_ADMIN')) {
+               if ($this->checker->isGranted('ROLE_ADMIN')) {
+                       //Set title
+                       $this->context['title']['page'] = $this->translator->trans('Libre Air user list');
+
                        //Set section
-                       $section = $this->translator->trans('Libre Air users');
+                       $this->context['title']['section'] = $this->translator->trans('User');
 
                        //Set description
-                       $this->context['description'] = $this->translator->trans('Libre Air user list');
+                       $this->context['description'] = $this->translator->trans('Lists Libre air users');
                //Without admin role
                } else {
+                       //Set title
+                       $this->context['title']['page'] = $this->translator->trans('Libre Air organizer list');
+
                        //Set section
-                       $section = $this->translator->trans('Libre Air organizers');
+                       $this->context['title']['section'] = $this->translator->trans('Organizer');
 
                        //Set description
-                       $this->context['description'] = $this->translator->trans('Libre Air organizers list');
+                       $this->context['description'] = $this->translator->trans('Lists Libre air organizers');
                }
 
                //Set keywords
@@ -472,26 +419,11 @@ class DefaultController extends AbstractController {
                        $this->translator->trans('Libre Air')
                ];
 
-               //Set title
-               $title = $this->translator->trans($this->config['site']['title']).' - '.$section;
-
                //Fetch users
-               $users = $doctrine->getRepository(User::class)->findUserGroupedByTranslatedGroup($this->translator);
-
-               //Compute period
-               $period = new \DatePeriod(
-                       //Start from first monday of week
-                       new \DateTime('Monday this week'),
-                       //Iterate on each day
-                       new \DateInterval('P1D'),
-                       //End with next sunday and 4 weeks
-                       new \DateTime(
-                               $this->isGranted('IS_AUTHENTICATED_REMEMBERED')?'Monday this week + 3 week':'Monday this week + 2 week'
-                       )
-               );
+               $users = $this->doctrine->getRepository(User::class)->findIndexByGroupId();
 
                //With admin role
-               if ($this->isGranted('ROLE_ADMIN')) {
+               if ($this->checker->isGranted('ROLE_ADMIN')) {
                        //Display all users
                        $this->context['groups'] = $users;
                //Without admin role
@@ -500,12 +432,8 @@ class DefaultController extends AbstractController {
                        $this->context['users'] = $users[$this->translator->trans('Senior')];
                }
 
-               //Fetch locations
-               //XXX: we want to display all active locations anyway
-               $locations = $doctrine->getRepository(Location::class)->findTranslatedSortedByPeriod($this->translator, $period);
-
                //Render the view
-               return $this->render('@RapsysAir/user/index.html.twig', ['title' => $title, 'section' => $section, 'locations' => $locations]+$this->context);
+               return $this->render('@RapsysAir/user/index.html.twig', $this->context);
        }
 
        /**
@@ -518,218 +446,337 @@ class DefaultController extends AbstractController {
         *
         * @return Response The rendered view
         */
-       public function userView(Request $request, $id): Response {
-               //Fetch doctrine
-               $doctrine = $this->getDoctrine();
-
-               //Fetch user
-               if (empty($user = $doctrine->getRepository(User::class)->findOneById($id))) {
-                       throw $this->createNotFoundException($this->translator->trans('Unable to find user: %id%', ['%id%' => $id]));
+       public function userView(Request $request, int $id, ?string $user): Response {
+               //Get user
+               if (empty($this->context['user'] = $this->doctrine->getRepository(User::class)->findOneByIdAsArray($id, $this->locale))) {
+                       //Throw not found
+                       throw new NotFoundHttpException($this->translator->trans('Unable to find user: %id%', ['%id%' => $id]));
                }
 
-               //Get user token
-               $token = new UsernamePasswordToken($user, null, 'none', $user->getRoles());
-
-               //Check if guest
-               $isGuest = $this->get('rapsys_user.access_decision_manager')->decide($token, ['ROLE_GUEST']);
+               //Create token
+               $token = new AnonymousToken($this->context['user']['roles']);
 
                //Prevent access when not admin, user is not guest and not currently logged user
-               if (!$this->isGranted('ROLE_ADMIN') && empty($isGuest) && $user != $this->getUser()) {
-                       throw $this->createAccessDeniedException($this->translator->trans('Unable to access user: %id%', ['%id%' => $id]));
+               if (!($isAdmin = $this->checker->isGranted('ROLE_ADMIN')) && !($isGuest = $this->decision->decide($token, ['ROLE_GUEST']))) {
+                       //Throw access denied
+                       throw new AccessDeniedException($this->translator->trans('Unable to access user: %id%', ['%id%' => $id]));
                }
 
-               //Set section
-               $section = $user->getPseudonym();
+               //With invalid user slug
+               if ($this->context['user']['slug'] !== $user) {
+                       //Redirect to cleaned url
+                       return $this->redirectToRoute('rapsys_air_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+               }
 
-               //Set title
-               $title = $this->translator->trans($this->config['site']['title']).' - '.$section;
+               //Fetch calendar
+               $this->context['calendar'] = $this->doctrine->getRepository(Session::class)->findAllByPeriodAsCalendarArray($this->period, !$this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED'), null, null, $id);
 
-               //Set description
-               $this->context['description'] = $this->translator->trans('%pseudonym% outdoor Argentine Tango session calendar', [ '%pseudonym%' => $user->getPseudonym() ]);
+               //Get locations at less than 2 km
+               $this->context['locations'] = $this->doctrine->getRepository(Location::class)->findAllByUserIdAsArray($id, $this->period, 2);
 
-               //Set keywords
-               $this->context['keywords'] = [
-                       $user->getPseudonym(),
-                       $this->translator->trans('outdoor'),
-                       $this->translator->trans('Argentine Tango'),
-                       $this->translator->trans('calendar')
-               ];
+               //Set ats
+               $ats = [];
 
-               //Compute period
-               $period = new \DatePeriod(
-                       //Start from first monday of week
-                       new \DateTime('Monday this week'),
-                       //Iterate on each day
-                       new \DateInterval('P1D'),
-                       //End with next sunday and 4 weeks
-                       new \DateTime(
-                               $this->isGranted('IS_AUTHENTICATED_REMEMBERED')?'Monday this week + 3 week':'Monday this week + 2 week'
-                       )
-               );
+               //Set dances
+               $dances = [];
 
-               //Fetch calendar
-               //TODO: highlight with current session route parameter
-               $calendar = $doctrine->getRepository(Session::class)->fetchUserCalendarByDatePeriod($this->translator, $period, $isGuest?$id:null, $request->get('session'), $request->getLocale());
-
-               //Fetch locations
-               //XXX: we want to display all active locations anyway
-               $locations = $doctrine->getRepository(Location::class)->findTranslatedSortedByPeriod($this->translator, $period, $id);
-
-               //Create user form for admin or current user
-               if ($this->isGranted('ROLE_ADMIN') || $user == $this->getUser()) {
-                       //Create SnippetType form
-                       $userForm = $this->createForm('Rapsys\AirBundle\Form\RegisterType', $user, [
-                               //Set action
-                               'action' => $this->generateUrl('rapsys_air_user_view', ['id' => $id]),
-                               //Set the form attribute
-                               'attr' => [ 'class' => 'col' ],
-                               //Set civility class
-                               'civility_class' => Civility::class,
-                               //Disable mail
-                               'mail' => $this->isGranted('ROLE_ADMIN'),
-                               //Disable password
-                               'password' => false
-                       ]);
-
-                       //Init user to context
-                       $this->context['forms']['user'] = $userForm->createView();
-
-                       //Check if submitted
-                       if ($request->isMethod('POST')) {
-                               //Refill the fields in case the form is not valid.
-                               $userForm->handleRequest($request);
-
-                               //Handle invalid form
-                               if (!$userForm->isSubmitted() || !$userForm->isValid()) {
-                                       //Render the view
-                                       return $this->render('@RapsysAir/user/view.html.twig', ['id' => $id, 'title' => $title, 'section' => $section, 'calendar' => $calendar, 'locations' => $locations]+$this->context);
-                               }
-
-                               //Get data
-                               $data = $userForm->getData();
+               //Set indoors
+               $indoors = [];
 
-                               //Get manager
-                               $manager = $doctrine->getManager();
+               //Set ins
+               $ins = [];
 
-                               //Queue snippet save
-                               $manager->persist($data);
+               //Set insides
+               $insides = [];
 
-                               //Flush to get the ids
-                               $manager->flush();
+               //Set locations
+               $locations = [];
 
-                               //Add notice
-                               $this->addFlash('notice', $this->translator->trans('User %id% updated', ['%id%' => $id]));
+               //Set types
+               $types = [];
 
-                               //Extract and process referer
-                               if ($referer = $request->headers->get('referer')) {
-                                       //Create referer request instance
-                                       $req = Request::create($referer);
+               //Iterate on each calendar
+               foreach($this->context['calendar'] as $date => $calendar) {
+                       //Iterate on each session
+                       foreach($calendar['sessions'] as $sessionId => $session) {
+                               //Add dance
+                               $dances[$session['application']['dance']['name']] = $session['application']['dance']['name'];
 
-                                       //Get referer path
-                                       $path = $req->getPathInfo();
+                               //Add types
+                               $types[$session['application']['dance']['type']] = lcfirst($session['application']['dance']['type']);
 
-                                       //Get referer query string
-                                       $query = $req->getQueryString();
+                               //Add indoors
+                               $indoors[$session['location']['indoor']?'indoor':'outdoor'] = $this->translator->trans($session['location']['indoor']?'indoor':'outdoor');
 
-                                       //Remove script name
-                                       $path = str_replace($request->getScriptName(), '', $path);
+                               //Add insides
+                               $insides[$session['location']['indoor']?'inside':'outside'] = $this->translator->trans($session['location']['indoor']?'inside':'outside');
 
-                                       //Try with referer path
-                                       try {
-                                               //Save old context
-                                               $oldContext = $this->router->getContext();
+                               //Add ats
+                               $ats[$session['location']['id']] = $session['location']['at'];
 
-                                               //Force clean context
-                                               //XXX: prevent MethodNotAllowedException because current context method is POST in onevendor/symfony/routing/Matcher/Dumper/CompiledUrlMatcherTrait.php+42
-                                               $this->router->setContext(new RequestContext());
+                               //Add ins
+                               $ins[$session['location']['id']] = $session['location']['in'];
 
-                                               //Retrieve route matching path
-                                               $route = $this->router->match($path);
+                               //Session with application user id
+                               if (!empty($session['application']['user']['id']) && $session['application']['user']['id'] == $id) {
+                                       //Add location
+                                       $locations[$session['location']['id']] = $session['location'];
+                               }
+                       }
+               }
 
-                                               //Reset context
-                                               $this->router->setContext($oldContext);
+               //Set modified
+               //XXX: dance modified is already computed inside calendar modified
+               $this->modified = max(array_merge([$this->context['user']['modified']], array_map(function ($v) { return $v['modified']; }, array_merge($this->context['calendar'], $this->context['locations']))));
 
-                                               //Clear old context
-                                               unset($oldContext);
+               //Create response
+               $response = new Response();
 
-                                               //Extract name
-                                               $name = $route['_route'];
+               //With logged user
+               if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
+                       //Set last modified
+                       $response->setLastModified(new \DateTime('-1 year'));
 
-                                               //Remove route and controller from route defaults
-                                               unset($route['_route'], $route['_controller']);
+                       //Set as private
+                       $response->setPrivate();
+               //Without logged user
+               } else {
+                       //Set etag
+                       //XXX: only for public to force revalidation by last modified
+                       $response->setEtag(md5(serialize(array_merge($this->context['user'], $this->context['calendar'], $this->context['locations']))));
 
-                                               //Check if user view route
-                                               if ($name == 'rapsys_air_user_view' && !empty($route['id'])) {
-                                                       //Replace id
-                                                       $route['id'] = $data->getId();
-                                               //Other routes
-                                               } else {
-                                                       //Set user
-                                                       $route['user'] = $data->getId();
-                                               }
+                       //Set last modified
+                       $response->setLastModified($this->modified);
 
-                                               //Generate url
-                                               return $this->redirectToRoute($name, $route);
-                                       //No route matched
-                                       } catch(MethodNotAllowedException|ResourceNotFoundException $e) {
-                                               //Unset referer to fallback to default route
-                                               unset($referer);
-                                       }
-                               }
+                       //Set as public
+                       $response->setPublic();
 
-                               //Redirect to cleanup the form
-                               return $this->redirectToRoute('rapsys_air', ['user' => $data->getId()]);
+                       //Without role and modification
+                       if ($response->isNotModified($request)) {
+                               //Return 304 response
+                               return $response;
                        }
                }
 
-               //Create snippet forms for role_guest
-               if ($this->isGranted('ROLE_ADMIN') || ($this->isGranted('ROLE_GUEST') && $user == $this->getUser())) {
-                       //Fetch all user snippet
-                       $snippets = $doctrine->getRepository(Snippet::class)->findByLocaleUserId($request->getLocale(), $id);
+               //Add multi map
+               $this->context['multimap'] = $this->map->getMultiMap($this->context['user']['multimap'], $this->modified->getTimestamp(), $this->context['locations']);
 
-                       //Rekey by location id
-                       $snippets = array_reduce($snippets, function($carry, $item){$carry[$item->getLocation()->getId()] = $item; return $carry;}, []);
+               //Set keywords
+               $this->context['keywords'] = [
+                       $this->context['user']['pseudonym'],
+                       $this->translator->trans('calendar'),
+                       $this->translator->trans('Libre Air')
+               ];
 
-                       //Init snippets to context
-                       $this->context['forms']['snippets'] = [];
+               //Set cities
+               $cities = array_unique(array_map(function ($v) { return $v['city']; }, $locations));
 
-                       //Iterate on locations
-                       foreach($locations as $locationId => $location) {
-                               //Init snippet
-                               $snippet = new Snippet();
+               //Set titles
+               $titles = array_map(function ($v) { return $v['title']; }, $locations);
+
+               //Insert dances in keywords
+               array_splice($this->context['keywords'], 1, 0, array_merge($types, $dances, $indoors, $insides, $titles, $cities));
 
-                               //Set default locale
-                               $snippet->setLocale($request->getLocale());
+               //Deduplicate ins
+               $ins = array_unique($ins);
 
-                               //Set default user
-                               $snippet->setUser($user);
+               //Get textual dances
+               $dances = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($dances, 0, -1))], array_slice($dances, -1)), 'strlen'));
 
-                               //Set default location
-                               $snippet->setLocation($doctrine->getRepository(Location::class)->findOneById($locationId));
+               //Get textual types
+               $types = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($types, 0, -1))], array_slice($types, -1)), 'strlen'));
 
+               //Get textual indoors
+               $indoors = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($indoors, 0, -1))], array_slice($indoors, -1)), 'strlen'));
+
+               //Get textual ats
+               $ats = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($ats, 0, -1))], array_slice($ats, -1)), 'strlen'));
+
+               //Get textual ins
+               $ins = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($ins, 0, -1))], array_slice($ins, -1)), 'strlen'));
+
+               //Set title
+               $this->context['title']['page'] = $this->translator->trans('%pseudonym% organizer', ['%pseudonym%' => $this->context['user']['pseudonym']]);
+
+               //Set section
+               $this->context['title']['section'] = $this->translator->trans('User');
+
+               //With locations
+               if (!empty($locations)) {
+                       //Set description
+                       $this->context['description'] = ucfirst($this->translator->trans('%dances% %types% %indoors% calendar %ats% %ins% %pseudonym%', ['%dances%' => $dances, '%types%' => $types, '%indoors%' => $indoors, '%ats%' => $ats, '%ins%' => $ins, '%pseudonym%' => $this->translator->trans('by %pseudonym%', ['%pseudonym%' => $this->context['user']['pseudonym']])]));
+               //Without locations
+               } else {
+                       //Set description
+                       $this->context['description'] = $this->translator->trans('%pseudonym% calendar', ['%pseudonym%' => $this->context['user']['pseudonym']]);
+               }
+
+               //Set user description
+               $this->context['locations_description'] = $this->translator->trans('Libre Air %pseudonym% location list', ['%pseudonym%' => $this->translator->trans('by %pseudonym%', ['%pseudonym%' => $this->context['user']['pseudonym']])]);
+
+               //Set alternates
+               $this->context['alternates'] += $this->context['user']['alternates'];
+
+               //Create snippet forms for role_guest
+               //TODO: optimize this call
+               if ($isAdmin || $isGuest && $this->security->getUser() && $this->context['user']['id'] == $this->security->getUser()->getId()) {
+                       //Fetch all user snippet
+                       $snippets = $this->doctrine->getRepository(Snippet::class)->findByUserIdLocaleIndexByLocationId($id, $this->locale);
+
+                       //Get user
+                       $user = $this->doctrine->getRepository(User::class)->findOneById($id);
+
+                       //Iterate on locations
+                       foreach($this->context['locations'] as $locationId => $location) {
                                //With existing snippet
-                               if (!empty($snippets[$locationId])) {
-                                       $snippet = $snippets[$locationId];
-                                       $action = $this->generateUrl('rapsys_air_snippet_edit', ['id' => $snippet->getId()]);
-                               //Without snippet
+                               if (isset($snippets[$location['id']])) {
+                                       //Set existing in current
+                                       $current = $snippets[$location['id']];
+                               //Without existing snippet
                                } else {
-                                       $action = $this->generateUrl('rapsys_air_snippet_add', ['location' => $locationId]);
+                                       //Init snippet
+                                       $current = new Snippet();
+
+                                       //Set default locale
+                                       $current->setLocale($this->locale);
+
+                                       //Set default user
+                                       $current->setUser($user);
+
+                                       //Set default location
+                                       $current->setLocation($this->doctrine->getRepository(Location::class)->findOneById($location['id']));
                                }
 
                                //Create SnippetType form
-                               $form = $this->container->get('form.factory')->createNamed('snipped_'.$request->getLocale().'_'.$locationId, 'Rapsys\AirBundle\Form\SnippetType', $snippet, [
-                                       //Set the action
-                                       'action' => $action,
-                                       //Set the form attribute
-                                       'attr' => []
-                               ]);
+                               $form = $this->factory->createNamed(
+                                       //Set form id
+                                       'snippet_'.$locationId.'_'.$id.'_'.$this->locale,
+                                       //Set form type
+                                       'Rapsys\AirBundle\Form\SnippetType',
+                                       //Set form data
+                                       $current
+                               );
+
+                               //Refill the fields in case of invalid form
+                               $form->handleRequest($request);
+
+                               //Handle submitted and valid form
+                               //TODO: add a delete snippet ?
+                               if ($form->isSubmitted() && $form->isValid()) {
+                                       //Get snippet
+                                       $snippet = $form->getData();
+
+                                       //Queue snippet save
+                                       $this->manager->persist($snippet);
+
+                                       //Flush to get the ids
+                                       $this->manager->flush();
+
+                                       //Add notice
+                                       $this->addFlash('notice', $this->translator->trans('Snippet for %user% %location% updated', ['%location%' => $location['at'], '%user%' => $this->context['user']['pseudonym']]));
+
+                                       //Redirect to cleaned url
+                                       return $this->redirectToRoute('rapsys_air_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+                               }
 
                                //Add form to context
                                $this->context['forms']['snippets'][$locationId] = $form->createView();
+
+                               //With location user source image
+                               if (($isFile = is_file($source = $this->config['path'].'/location/'.$location['id'].'/'.$id.'.png')) && ($mtime = stat($source)['mtime'])) {
+                                       //Set location image
+                                       $this->context['locations'][$locationId]['image'] = $this->image->getThumb($location['miniature'], $mtime, $source);
+                               }
+
+                               //Create ImageType form
+                               $form = $this->factory->createNamed(
+                                       //Set form id
+                                       'image_'.$locationId.'_'.$id,
+                                       //Set form type
+                                       'Rapsys\AirBundle\Form\ImageType',
+                                       //Set form data
+                                       [
+                                               //Set location
+                                               'location' => $location['id'],
+                                               //Set user
+                                               'user' => $id
+                                       ],
+                                       //Set form attributes
+                                       [
+                                               //Enable delete with image
+                                               'delete' => isset($this->context['locations'][$locationId]['image'])
+                                       ]
+                               );
+
+                               //Refill the fields in case of invalid form
+                               $form->handleRequest($request);
+
+                               //Handle submitted and valid form
+                               if ($form->isSubmitted() && $form->isValid()) {
+                                       //With delete
+                                       if ($form->has('delete') && $form->get('delete')->isClicked()) {
+                                               //With source and mtime
+                                               if ($isFile && !empty($source) && !empty($mtime)) {
+                                                       //Clear thumb
+                                                       $this->image->remove($mtime, $source);
+
+                                                       //Unlink file
+                                                       unlink($this->config['path'].'/location/'.$location['id'].'/'.$id.'.png');
+
+                                                       //Add notice
+                                                       $this->addFlash('notice', $this->translator->trans('Image for %user% %location% deleted', ['%location%' => $location['at'], '%user%' => $this->context['user']['pseudonym']]));
+
+                                                       //Redirect to cleaned url
+                                                       return $this->redirectToRoute('rapsys_air_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+                                               }
+                                       }
+
+                                       //With image
+                                       if ($image = $form->get('image')->getData()) {
+                                               //Check source path
+                                               if (!is_dir($dir = dirname($source))) {
+                                                       //Create filesystem object
+                                                       $filesystem = new Filesystem();
+
+                                                       try {
+                                                               //Create dir
+                                                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                                                               $filesystem->mkdir($dir, 0775);
+                                                       } catch (IOExceptionInterface $e) {
+                                                               //Throw error
+                                                               throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                                                       }
+                                               }
+
+                                               //Set source
+                                               $source = realpath($dir).'/'.basename($source);
+
+                                               //Create imagick object
+                                               $imagick = new \Imagick();
+
+                                               //Read image
+                                               $imagick->readImage($image->getRealPath());
+
+                                               //Save image
+                                               if (!$imagick->writeImage($source)) {
+                                                       //Throw error
+                                                       throw new \Exception(sprintf('Unable to write image "%s"', $source));
+                                               }
+
+                                               //Add notice
+                                               $this->addFlash('notice', $this->translator->trans('Image for %user% %location% updated', ['%location%' => $location['at'], '%user%' => $this->context['user']['pseudonym']]));
+
+                                               //Redirect to cleaned url
+                                               return $this->redirectToRoute('rapsys_air_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+                                       }
+                               }
+
+                               //Add form to context
+                               $this->context['forms']['images'][$locationId] = $form->createView();
                        }
                }
 
                //Render the view
-               return $this->render('@RapsysAir/user/view.html.twig', ['id' => $id, 'title' => $title, 'section' => $section, 'calendar' => $calendar, 'locations' => $locations]+$this->context);
+               return $this->render('@RapsysAir/user/view.html.twig', ['id' => $id]+$this->context);
        }
 }