X-Git-Url: https://git.rapsys.eu/airbundle/blobdiff_plain/866d4c381ff3365d3dd00a6b8266f6a47aa46c12..ba086d5c3cb0ea91d97f2575abaa95075d733cc5:/Controller/DefaultController.php

diff --git a/Controller/DefaultController.php b/Controller/DefaultController.php
index f15ae32..9554557 100644
--- a/Controller/DefaultController.php
+++ b/Controller/DefaultController.php
@@ -1,44 +1,109 @@
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys AirBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
 
 namespace Rapsys\AirBundle\Controller;
 
-#use Symfony\Bundle\FrameworkBundle\Controller\Controller;
-use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
-use Psr\Container\ContainerInterface;
-use Symfony\Bundle\FrameworkBundle\Translation\Translator;
-use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+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\Mime\Address;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+
+use Rapsys\AirBundle\Entity\Dance;
+use Rapsys\AirBundle\Entity\Location;
 use Rapsys\AirBundle\Entity\Session;
-use Rapsys\AirBundle\Entity\Application;
-use Symfony\Component\Form\FormError;
+use Rapsys\AirBundle\Entity\Snippet;
+use Rapsys\AirBundle\Entity\User;
+use Rapsys\AirBundle\Token\AnonymousToken;
 
-#class DefaultController extends Controller {
+/**
+ * {@inheritdoc}
+ */
 class DefaultController extends AbstractController {
-	//Container instance
-	protected $container;
+	/**
+	 * The about page
+	 *
+	 * @desc Display the about informations
+	 *
+	 * @param Request $request The request instance
+	 * @return Response The rendered view
+	 */
+	public function about(Request $request): Response {
+		//Set page
+		$this->context['title']['page'] = $this->translator->trans('About');
+
+		//Set description
+		$this->context['description'] = $this->translator->trans('Libre Air about');
+
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->translator->trans('about'),
+			$this->translator->trans('Libre Air')
+		];
 
-	//Translator instance
-	protected $translator;
-
-	public function __construct(ContainerInterface $container, Translator $translator) {
-		//Set the container
-		$this->container = $container;
+		//Render template
+		$response = $this->render('@RapsysAir/default/about.html.twig', $this->context);
+		$response->setEtag(md5($response->getContent()));
+		$response->setPublic();
+		$response->isNotModified($request);
 
-		//Set the translator
-		$this->translator = $translator;
+		//Return response
+		return $response;
 	}
 
-	public function contactAction(Request $request) {
-		//Set section
-		$section = $this->translator->trans('Contact');
-
-		//Set title
-		$title = $section.' - '.$this->translator->trans($this->container->getParameter('rapsys_air.title'));
+	/**
+	 * The contact page
+	 *
+	 * @desc Send a contact mail to configured contact
+	 *
+	 * @param Request $request The request instance
+	 *
+	 * @return Response The rendered view or redirection
+	 */
+	public function contact(Request $request): Response {
+		//Set page
+		$this->context['title']['page'] = $this->translator->trans('Contact');
+
+		//Set description
+		$this->context['description'] = $this->translator->trans('Contact Libre Air');
+
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->translator->trans('contact'),
+			$this->translator->trans('Libre Air'),
+			$this->translator->trans('outdoor'),
+			$this->translator->trans('Argentine Tango'),
+			$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, [
-			// To set the action use $this->generateUrl('route_identifier')
+		$form = $this->factory->create('Rapsys\AirBundle\Form\ContactType', $data, [
 			'action' => $this->generateUrl('rapsys_air_contact'),
 			'method' => 'POST'
 		]);
@@ -47,276 +112,671 @@ 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();
 
-				//Get contact name
-				$contactName = $this->container->getParameter('rapsys_air.contact_name');
-
-				//Get contact mail
-				$contactMail = $this->container->getParameter('rapsys_air.contact_mail');
-
-				//Get logo
-				$logo = $this->container->getParameter('rapsys_air.logo');
-
-				//Get title
-				$title = $this->translator->trans($this->container->getParameter('rapsys_air.title'));
-
-				//Get subtitle
-				$subtitle = $this->translator->trans('Hi,').' '.$contactName;
-
-				//Create sendmail transport
-				$transport = new \Swift_SendmailTransport();
-
-				//Create mailer using transport
-				$mailer = new \Swift_Mailer($transport);
-
-				//Create the message
-				($message = new \Swift_Message($data['subject']))
-					#->setSubject($data['subject'])
-					->setFrom([$data['mail'] => $data['name']])
-					->setTo([$contactMail => $contactName])
-					->setBody($data['message'])
-					->addPart(
-						$this->renderView(
-							'@RapsysAir/mail/generic.html.twig',
-							[
-								'logo' => $logo,
-								'title' => $title,
-								'subtitle' => $subtitle,
-								'home' => $this->get('router')->generate('rapsys_air_homepage', [], UrlGeneratorInterface::ABSOLUTE_URL),
-								'subject' => $data['subject'],
-								'contact_name' => $contactName,
-								'message' => strip_tags($data['message'])
-							]
-						),
-						'text/html'
+				//Create message
+				$message = (new TemplatedEmail())
+					//Set sender
+					->from(new Address($data['mail'], $data['name']))
+					//Set recipient
+					->to(new Address($this->context['contact']['address'], $this->context['contact']['name']))
+					//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
 					);
 
-				//Send the message
-				if ($mailer->send($message)) {
-					//Redirect to cleanup the form
-					return $this->redirectToRoute('rapsys_air_contact', ['sent' => 1]);
+				//Try sending message
+				//XXX: mail delivery may silently fail
+				try {
+					//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'));
+				//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->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']['address']])));
+					}
 				}
 			}
 		}
 
 		//Render template
-		return $this->render('@RapsysAir/form/contact.html.twig', ['title' => $title, 'section' => $section, 'form' => $form->createView(), 'sent' => $request->query->get('sent', 0)]);
+		return $this->render('@RapsysAir/form/contact.html.twig', ['form' => $form->createView(), 'sent' => $request->query->get('sent', 0)]+$this->context);
 	}
 
-	public function indexAction() {
-		//Set section
-		$section = $this->translator->trans('Index');
+	/**
+	 * The index page
+	 *
+	 * Display session calendar
+	 *
+	 * @param Request $request The request instance
+	 * @return Response The rendered view
+	 */
+	public function index(Request $request): Response {
+		//Add cities
+		$this->context['cities'] = $this->doctrine->getRepository(Location::class)->findCitiesAsArray($this->period);
+
+		//Add calendar
+		$this->context['calendar'] = $this->doctrine->getRepository(Session::class)->findAllByPeriodAsCalendarArray($this->period, !$this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED'));
+
+		//Add dances
+		$this->context['dances'] = $this->doctrine->getRepository(Dance::class)->findNamesAsArray();
+
+		//Set modified
+		$this->modified = max(array_map(function ($v) { return $v['modified']; }, array_merge($this->context['calendar'], $this->context['cities'], $this->context['dances'])));
+
+		//Create response
+		$response = new Response();
+
+		//With logged user
+		if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
+			//Set last modified
+			$response->setLastModified(new \DateTime('-1 year'));
+
+			//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']))));
+
+			//Set last modified
+			$response->setLastModified($this->modified);
+
+			//Set as public
+			$response->setPublic();
+
+			//Without role and modification
+			if ($response->isNotModified($request)) {
+				//Return 304 response
+				return $response;
+			}
+		}
+
+		//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;
+				}
+			}
+
+			//Add multi
+			$this->context['multimap'] = $this->map->getMultiMap($this->translator->trans('Libre Air cities sector map'), $this->modified->getTimestamp(), $locations);
+
+			//Set cities
+			$cities = array_map(function ($v) { return $v['in']; }, $this->context['cities']);
+
+			//Set dances
+			$dances = array_map(function ($v) { return $v['name']; }, $this->context['dances']);
+		} else {
+			//Set cities
+			$cities = [];
+
+			//Set dances
+			$dances = [];
+		}
+
+		//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')
+				]
+			)
+		);
+
+		//Get textual cities
+		$cities = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($cities, 0, -1))], array_slice($cities, -1)), 'strlen'));
+
+		//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 title
-		$title = $section.' - '.$this->translator->trans($this->container->getParameter('rapsys_air.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 facebook type
+		//XXX: only valid for home page
+		$this->context['facebook']['metas']['og:type'] = 'website';
+
+		//Render the view
+		return $this->render('@RapsysAir/default/index.html.twig', $this->context, $response);
+	}
+
+	/**
+	 * The organizer regulation page
+	 *
+	 * @desc Display the organizer regulation policy
+	 *
+	 * @param Request $request The request instance
+	 * @return Response The rendered view
+	 */
+	public function organizerRegulation(Request $request): Response {
+		//Set page
+		$this->context['title']['page'] = $this->translator->trans('Organizer regulation');
+
+		//Set description
+		$this->context['description'] = $this->translator->trans('Libre Air organizer regulation');
+
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->translator->trans('organizer regulation'),
+			$this->translator->trans('Libre Air')
+		];
 
 		//Render template
-		return $this->render('@RapsysAir/page/index.html.twig', ['title' => $title, 'section' => $section]);
+		$response = $this->render('@RapsysAir/default/organizer_regulation.html.twig', $this->context);
+
+		//Set as cachable
+		$response->setEtag(md5($response->getContent()));
+		$response->setPublic();
+		$response->isNotModified($request);
+
+		//Return response
+		return $response;
 	}
 
-	public function adminAction(Request $request) {
-		//Prevent non-admin to access here
-		//TODO: maybe check if user is connected 1st ?
-		$this->denyAccessUnlessGranted('ROLE_ADMIN', null, 'Unable to access this page!');
+	/**
+	 * The terms of service page
+	 *
+	 * @desc Display the terms of service policy
+	 *
+	 * @param Request $request The request instance
+	 * @return Response The rendered view
+	 */
+	public function termsOfService(Request $request): Response {
+		//Set page
+		$this->context['title']['page'] = $this->translator->trans('Terms of service');
+
+		//Set description
+		$this->context['description'] = $this->translator->trans('Libre Air terms of service');
+
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->translator->trans('terms of service'),
+			$this->translator->trans('Libre Air')
+		];
 
-		//Set section
-		$section = $this->translator->trans('Admin');
+		//Render template
+		$response = $this->render('@RapsysAir/default/terms_of_service.html.twig', $this->context);
 
-		//Set title
-		$title = $section.' - '.$this->translator->trans($this->container->getParameter('rapsys_air.title'));
+		//Set as cachable
+		$response->setEtag(md5($response->getContent()));
+		$response->setPublic();
+		$response->isNotModified($request);
 
-		//Create the form according to the FormType created previously.
-		//And give the proper parameters
-		$form = $this->createForm('Rapsys\AirBundle\Form\ApplicationType', null, [
-			// To set the action use $this->generateUrl('route_identifier')
-			'action' => $this->generateUrl('rapsys_air_admin'),
-			'method' => 'POST',
-			'attr' => [ 'class' => 'form_col' ]
-		]);
+		//Return response
+		return $response;
+	}
 
-		//Get doctrine
-		$doctrine = $this->getDoctrine();
+	/**
+	 * The frequently asked questions page
+	 *
+	 * @desc Display the frequently asked questions
+	 *
+	 * @param Request $request The request instance
+	 * @return Response The rendered view
+	 */
+	public function frequentlyAskedQuestions(Request $request): Response {
+		//Set page
+		$this->context['title']['page'] = $this->translator->trans('Frequently asked questions');
+
+		//Set description
+		$this->context['description'] = $this->translator->trans('Libre Air frequently asked questions');
+
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->translator->trans('frequently asked questions'),
+			$this->translator->trans('faq'),
+			$this->translator->trans('Libre Air')
+		];
 
-		//Handle request
-		if ($request->isMethod('POST')) {
-			// Refill the fields in case the form is not valid.
-			$form->handleRequest($request);
+		//Render template
+		$response = $this->render('@RapsysAir/default/frequently_asked_questions.html.twig', $this->context);
 
-			if ($form->isValid()) {
-				//Get data
-				$data = $form->getData();
+		//Set as cachable
+		$response->setEtag(md5($response->getContent()));
+		$response->setPublic();
+		$response->isNotModified($request);
 
-				//Get manager
-				$manager = $doctrine->getManager();
+		//Return response
+		return $response;
+	}
 
-				//Protect session fetching
-				try {
-					$session = $doctrine->getRepository(Session::class)->findOneByLocationSlotDate($data['location'], $data['slot'], $data['date']);
-				//Catch no session case
-				} catch (\Doctrine\ORM\NoResultException $e) {
-					//Create the session
-					$session = new Session();
-					$session->setLocation($data['location']);
-					$session->setSlot($data['slot']);
-					$session->setDate($data['date']);
-					$session->setCreated(new \DateTime('now'));
-					$session->setUpdated(new \DateTime('now'));
-					$manager->persist($session);
-					//Flush to get the ids
-					#$manager->flush();
-				}
+	/**
+	 * List all users
+	 *
+	 * @desc Display all user with a group listed as users
+	 *
+	 * @param Request $request The request instance
+	 *
+	 * @return Response The rendered view
+	 */
+	public function userIndex(Request $request): Response {
+		//With admin role
+		if ($this->checker->isGranted('ROLE_ADMIN')) {
+			//Set title
+			$this->context['title']['page'] = $this->translator->trans('Libre Air user list');
+
+			//Set section
+			$this->context['title']['section'] = $this->translator->trans('User');
+
+			//Set description
+			$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
+			$this->context['title']['section'] = $this->translator->trans('Organizer');
+
+			//Set description
+			$this->context['description'] = $this->translator->trans('Lists Libre air organizers');
+		}
 
-				//Init application
-				$application = false;
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->translator->trans('users'),
+			$this->translator->trans('user list'),
+			$this->translator->trans('listing'),
+			$this->translator->trans('Libre Air')
+		];
+
+		//Fetch users
+		$users = $this->doctrine->getRepository(User::class)->findIndexByGroupId();
+
+		//With admin role
+		if ($this->checker->isGranted('ROLE_ADMIN')) {
+			//Display all users
+			$this->context['groups'] = $users;
+		//Without admin role
+		} else {
+			//Only display senior organizers
+			$this->context['users'] = $users[$this->translator->trans('Senior')];
+		}
 
-				//Protect application fetching
-				try {
-					//TODO: handle admin case where we provide a user in extra
-					$application = $doctrine->getRepository(Application::class)->findOneBySessionUser($session, $this->getUser());
-
-					//Add error message to mail field
-					$form->get('slot')->addError(new FormError($this->translator->trans('Application already exists')));
-				//Catch no application cases
-				//XXX: combine these catch when php 7.1 is available
-				} catch (\Doctrine\ORM\NoResultException $e) {
-				//Catch invalid argument because session is not already persisted
-				} catch(\Doctrine\ORM\ORMInvalidArgumentException $e) {
-				}
+		//Render the view
+		return $this->render('@RapsysAir/user/index.html.twig', $this->context);
+	}
 
-				//Create new application if none found
-				if (!$application) {
-					//Create the application
-					$application = new Application();
-					$application->setSession($session);
-					//TODO: handle admin case where we provide a user in extra
-					$application->setUser($this->getUser());
-					$application->setCreated(new \DateTime('now'));
-					$application->setUpdated(new \DateTime('now'));
-					$manager->persist($application);
+	/**
+	 * List all sessions for the user
+	 *
+	 * @desc Display all sessions for the user with an application or login form
+	 *
+	 * @param Request $request The request instance
+	 * @param int $id The user id
+	 *
+	 * @return Response The rendered view
+	 */
+	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]));
+		}
 
-					//Flush to get the ids
-					$manager->flush();
+		//Create token
+		$token = new AnonymousToken($this->context['user']['roles']);
 
-					//Add notice in flash message
-					$this->addFlash('notice', $this->translator->trans('Application request the %date% for %location% on the slot %slot% saved', ['%location%' => $data['location']->getTitle(), '%slot%' => $data['slot']->getTitle(), '%date%' => $data['date']->format('Y-m-d')]));
+		//Prevent access when not admin, user is not guest and not currently logged user
+		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]));
+		}
 
-					//Redirect to cleanup the form
-					return $this->redirectToRoute('rapsys_air_admin');
-				}
-			}
+		//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']]);
 		}
 
-		//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('Monday this week + 5 week')
-		);
+		//Fetch calendar
+		$this->context['calendar'] = $this->doctrine->getRepository(Session::class)->findAllByPeriodAsCalendarArray($this->period, !$this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED'), null, null, $id);
 
-		//Fetch sessions
-		$sessions = $doctrine->getRepository(Session::class)->findByDatePeriod($period);
-
-		//Init calendar
-		$calendar = [];
-		
-		//Init month
-		$month = null;
-
-		//Iterate on each day
-		foreach($period as $date) {
-			//Init day in calendar
-			$calendar[$Ymd = $date->format('Ymd')] = [
-				'title' => $date->format('d'),
-				'class' => [],
-				'sessions' => []
-			];
-			//Append month for first day of month
-			if ($month != $date->format('m')) {
-				$month = $date->format('m');
-				$calendar[$Ymd]['title'] .= '/'.$month;
-			}
-			//Deal with today
-			if ($date->format('U') == ($today = strtotime('today'))) {
-				$calendar[$Ymd]['title'] .= '/'.$month;
-				$calendar[$Ymd]['current'] = true;
-				$calendar[$Ymd]['class'][] =  'current';
-			}
-			//Disable passed days
-			if ($date->format('U') < $today) {
-				$calendar[$Ymd]['disabled'] = true;
-				$calendar[$Ymd]['class'][] =  'disabled';
-			}
-			//Set next month days
-			if ($date->format('m') > date('m')) {
-				$calendar[$Ymd]['next'] = true;
-				$calendar[$Ymd]['class'][] =  'next';
-			}
-			//Iterate on each session to find the one of the day
-			foreach($sessions as $session) {
-				if (($sessionYmd = $session->getDate()->format('Ymd')) == $Ymd) {
-					//Count number of application
-					$count = count($session->getApplications());
-
-					//Compute classes
-					$class = [];
-					if ($session->getApplication()) {
-						$class[] = 'granted';
-					} elseif ($count == 0) {
-						$class[] = 'orphaned';
-					} elseif ($count > 1) {
-						$class[] = 'disputed';
-					} else {
-						$class[] = 'pending';
-					}
+		//Get locations at less than 2 km
+		$this->context['locations'] = $this->doctrine->getRepository(Location::class)->findAllByUserIdAsArray($id, $this->period, 2);
+
+		//Set ats
+		$ats = [];
+
+		//Set dances
+		$dances = [];
+
+		//Set indoors
+		$indoors = [];
+
+		//Set ins
+		$ins = [];
+
+		//Set insides
+		$insides = [];
+
+		//Set locations
+		$locations = [];
+
+		//Set types
+		$types = [];
+
+		//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'];
+
+				//Add types
+				$types[$session['application']['dance']['type']] = lcfirst($session['application']['dance']['type']);
+
+				//Add indoors
+				$indoors[$session['location']['indoor']?'indoor':'outdoor'] = $this->translator->trans($session['location']['indoor']?'indoor':'outdoor');
+
+				//Add insides
+				$insides[$session['location']['indoor']?'inside':'outside'] = $this->translator->trans($session['location']['indoor']?'inside':'outside');
+
+				//Add ats
+				$ats[$session['location']['id']] = $session['location']['at'];
+
+				//Add ins
+				$ins[$session['location']['id']] = $session['location']['in'];
 
-					//Add the session
-					$calendar[$Ymd]['sessions'][$session->getSlot()->getId().$session->getLocation()->getId()] = [
-						'id' => $session->getId(),
-						'title' => ($count > 1?'['.$count.'] ':'').$session->getSlot()->getTitle().' '.$session->getLocation()->getTitle(),
-						'class' => $class
-					];
+				//Session with application user id
+				if (!empty($session['application']['user']['id']) && $session['application']['user']['id'] == $id) {
+					//Add location
+					$locations[$session['location']['id']] = $session['location'];
 				}
 			}
+		}
+
+		//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']))));
+
+		//Create response
+		$response = new Response();
+
+		//With logged user
+		if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
+			//Set last modified
+			$response->setLastModified(new \DateTime('-1 year'));
 
-			//Sort sessions
-			ksort($calendar[$Ymd]['sessions']);
+			//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']))));
+
+			//Set last modified
+			$response->setLastModified($this->modified);
+
+			//Set as public
+			$response->setPublic();
+
+			//Without role and modification
+			if ($response->isNotModified($request)) {
+				//Return 304 response
+				return $response;
+			}
 		}
 
-		return $this->render('@RapsysAir/admin/index.html.twig', ['title' => $title, 'section' => $section, 'form' => $form->createView(), 'calendar' => $calendar]);
-	}
+		//Add multi map
+		$this->context['multimap'] = $this->map->getMultiMap($this->context['user']['multimap'], $this->modified->getTimestamp(), $this->context['locations']);
 
-	public function sessionAction(Request $request, $id) {
-		/*header('Content-Type: text/plain');
-		var_dump($calendar);
-		exit;*/
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->context['user']['pseudonym'],
+			$this->translator->trans('calendar'),
+			$this->translator->trans('Libre Air')
+		];
 
-		//Set section
-		$section = $this->translator->trans('Session %id%', ['%id%' => $id]);
+		//Set cities
+		$cities = array_unique(array_map(function ($v) { return $v['city']; }, $locations));
+
+		//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));
+
+		//Deduplicate ins
+		$ins = array_unique($ins);
+
+		//Get textual dances
+		$dances = implode($this->translator->trans(' and '), array_filter(array_merge([implode(', ', array_slice($dances, 0, -1))], array_slice($dances, -1)), 'strlen'));
+
+		//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
-		$title = $section.' - '.$this->translator->trans($this->container->getParameter('rapsys_air.title'));
+		$this->context['title']['page'] = $this->translator->trans('%pseudonym% organizer', ['%pseudonym%' => $this->context['user']['pseudonym']]);
 
-		//Create the form according to the FormType created previously.
-		//And give the proper parameters
-		/*$form = $this->createForm('Rapsys\AirBundle\Form\ApplicationType', null, [
-			// To set the action use $this->generateUrl('route_identifier')
-			'action' => $this->generateUrl('rapsys_air_admin'),
-			'method' => 'POST',
-			'attr' => [ 'class' => 'form_col' ]
-		]);*/
+		//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 (isset($snippets[$location['id']])) {
+					//Set existing in current
+					$current = $snippets[$location['id']];
+				//Without existing snippet
+				} else {
+					//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->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);
 
-		//Get doctrine
-		$doctrine = $this->getDoctrine();
+				//Handle submitted and valid form
+				//TODO: add a delete snippet ?
+				if ($form->isSubmitted() && $form->isValid()) {
+					//Get snippet
+					$snippet = $form->getData();
 
-		//Fetch session
-		$session = $doctrine->getRepository(Session::class)->findOneById($id);
+					//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();
+			}
+		}
 
-		return $this->render('@RapsysAir/admin/session.html.twig', ['title' => $title, 'section' => $section, /*'form' => $form->createView(),*/ 'session' => $session]);
+		//Render the view
+		return $this->render('@RapsysAir/user/view.html.twig', ['id' => $id]+$this->context);
 	}
 }