X-Git-Url: https://git.rapsys.eu/airbundle/blobdiff_plain/dbd9139fe1367b8bd69b2d570d5c9831fbbe722f..dbe6f6710034af730e633e1c810ad7f020fe9551:/Controller/DefaultController.php

diff --git a/Controller/DefaultController.php b/Controller/DefaultController.php
index eeb520c..1459096 100644
--- a/Controller/DefaultController.php
+++ b/Controller/DefaultController.php
@@ -1,155 +1,81 @@
-<?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 Rapsys\AirBundle\Entity\Application;
-use Rapsys\AirBundle\Entity\Location;
-use Rapsys\AirBundle\Entity\Session;
-use Rapsys\AirBundle\Entity\Slot;
-use Rapsys\AirBundle\Entity\User;
 use Symfony\Bridge\Twig\Mime\TemplatedEmail;
-use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
-use Symfony\Component\DependencyInjection\ContainerInterface;
+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\RequestStack;
 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\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Routing\RouterInterface;
-use Symfony\Component\Translation\TranslatorInterface;
-use Rapsys\UserBundle\Utils\Slugger;
-use Symfony\Bundle\FrameworkBundle\Controller\ControllerTrait;
-use Symfony\Component\DependencyInjection\ContainerAwareTrait;
-
-
-class DefaultController {
-	use ControllerTrait {
-		//Rename render as _render
-		render as protected _render;
-	}
-
-	///Config array
-	protected $config;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
 
-	///Context array
-	protected $context;
-
-	///Router instance
-	protected $router;
-
-	///Translator instance
-	protected $translator;
-
-	/**
-	 * @var ContainerInterface
-	 */
-	protected $container;
+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\Token\AnonymousToken;
 
+/**
+ * {@inheritdoc}
+ */
+class DefaultController extends AbstractController {
 	/**
-	 * Inject container and translator interface
+	 * The about page
+	 *
+	 * Display the about informations
 	 *
-	 * @param ContainerInterface $container The container instance
-	 * @param RouterInterface $router The router instance
-	 * @param TranslatorInterface $translator The translator instance
+	 * @param Request $request The request instance
+	 * @return Response The rendered view
 	 */
-	public function __construct(ContainerInterface $container, RouterInterface $router, RequestStack $requestStack, TranslatorInterface $translator) {
-		//Retrieve config
-		$this->config = $container->getParameter($this->getAlias());
-
-		//Set the container
-		$this->container = $container;
-
-		//Set the router
-		$this->router = $router;
-
-		//Set the translator
-		$this->translator = $translator;
-
-		//Set the context
-		$this->context = [
-			'copy' => [
-				'by' => $translator->trans($this->config['copy']['by']),
-				'link' => $this->config['copy']['link'],
-				'long' => $translator->trans($this->config['copy']['long']),
-				'short' => $translator->trans($this->config['copy']['short']),
-				'title' => $this->config['copy']['title']
-			],
-			'site' => [
-				'ico' => $this->config['site']['ico'],
-				'logo' => $this->config['site']['logo'],
-				'png' => $this->config['site']['png'],
-				'svg' => $this->config['site']['svg'],
-				'title' => $translator->trans($this->config['site']['title']),
-				'url' => $router->generate($this->config['site']['url']),
-			],
-			'canonical' => null,
-			'alternates' => [],
-			'forms' => []
-		];
+	public function about(Request $request): Response {
+		//Set page
+		$this->context['title']['page'] = $this->translator->trans('About');
 
-		//Get current locale
-		#$currentLocale = $router->getContext()->getParameters()['_locale'];
-		$currentLocale = $requestStack->getCurrentRequest()->getLocale();
-
-		//Set translator locale
-		//XXX: allow LocaleSubscriber on the fly locale change for first page
-		$this->translator->setLocale($currentLocale);
+		//Set description
+		$this->context['description'] = $this->translator->trans('Libre Air about');
 
-		//Iterate on locales excluding current one
-		foreach($this->config['locales'] as $locale) {
-			//Set titles
-			$titles = [];
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->translator->trans('about'),
+			$this->translator->trans('Libre Air')
+		];
 
-			//Iterate on other locales
-			foreach(array_diff($this->config['locales'], [$locale]) as $other) {
-				$titles[$other] = $translator->trans($this->config['languages'][$locale], [], null, $other);
-			}
+		//Render template
+		$response = $this->render('@RapsysAir/default/about.html.twig', $this->context);
+		$response->setEtag(md5($response->getContent()));
+		$response->setPublic();
+		$response->isNotModified($request);
 
-			//Get context path
-			$path = $router->getContext()->getPathInfo();
-
-			//Retrieve route matching path
-			$route = $router->match($path);
-
-			//Get route name
-			$name = $route['_route'];
-
-			//Unset route name
-			unset($route['_route']);
-
-			//With current locale
-			if ($locale == $currentLocale) {
-				//Set locale locales context
-				$this->context['canonical'] = $router->generate($name, ['_locale' => $locale]+$route, UrlGeneratorInterface::ABSOLUTE_URL);
-			} else {
-				//Set locale locales context
-				$this->context['alternates'][] = [
-					'lang' => $locale,
-					'absolute' => $router->generate($name, ['_locale' => $locale]+$route, UrlGeneratorInterface::ABSOLUTE_URL),
-					'relative' => $router->generate($name, ['_locale' => $locale]+$route),
-					'title' => implode('/', $titles),
-					'translated' => $translator->trans($this->config['languages'][$locale], [], null, $locale)
-				];
-			}
-		}
+		//Return response
+		return $response;
 	}
 
 	/**
 	 * The contact page
 	 *
-	 * @desc Send a contact mail to configured contact
+	 * 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 {
-		//Set section
-		$section = $this->translator->trans('Contact');
+	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');
@@ -163,13 +89,23 @@ class DefaultController {
 			$this->translator->trans('calendar')
 		];
 
-		//Set title
-		$title = $this->translator->trans($this->config['site']['title']).' - '.$section;
+		//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, [
-			'action' => $this->generateUrl('rapsys_air_contact'),
+		$form = $this->factory->create('Rapsys\AirBundle\Form\ContactType', $data, [
+			'action' => $this->generateUrl('rapsysair_contact'),
+			'captcha' => true,
 			'method' => 'POST'
 		]);
 
@@ -177,7 +113,7 @@ class DefaultController {
 			// 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();
 
@@ -186,8 +122,7 @@ class DefaultController {
 					//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']['name']))
+					->to(new Address($this->context['contact']['address'], $this->context['contact']['name']))
 					//Set subject
 					->subject($data['subject'])
 
@@ -207,7 +142,7 @@ class DefaultController {
 				//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'));
@@ -215,92 +150,147 @@ class DefaultController {
 				} 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)])));
+						$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->config['contact']['mail']])));
+						$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)]+$this->context);
+		return $this->render('@RapsysAir/form/contact.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
+	 * Display session calendar
 	 *
 	 * @param Request $request The request instance
-	 *
 	 * @return Response The rendered view
 	 */
 	public function index(Request $request): Response {
-		//Fetch doctrine
-		$doctrine = $this->getDoctrine();
+		//Add cities
+		$this->context['cities'] = $this->doctrine->getRepository(Location::class)->findCitiesAsArray($this->period);
 
-		//Set section
-		$section = $this->translator->trans('Argentine Tango in Paris');
+		//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('Outdoor Argentine Tango session calendar in Paris');
+		//Add dances
+		$this->context['dances'] = $this->doctrine->getRepository(Dance::class)->findNamesAsArray();
 
-		//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 modified
+		$this->modified = max(array_map(function ($v) { return $v['modified']; }, array_merge($this->context['calendar'], $this->context['cities'], $this->context['dances'])));
 
-		//Set title
-		$title = $this->translator->trans($this->config['site']['title']).' - '.$section;
-
-		//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 + 4 week':'Monday this week + 2 week'
+		//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')
+				]
 			)
 		);
 
-		//Fetch calendar
-		$calendar = $doctrine->getRepository(Session::class)->fetchCalendarByDatePeriod($this->translator, $period, null, $request->get('session'), !$this->isGranted('IS_AUTHENTICATED_REMEMBERED'));
+		//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', ['title' => $title, 'section' => $section, '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
-		#$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);
 	}
 
 	/**
 	 * The organizer regulation page
 	 *
-	 * @desc Display the organizer regulation policy
+	 * Display the organizer regulation policy
 	 *
+	 * @param Request $request The request instance
 	 * @return Response The rendered view
 	 */
-	public function organizerRegulation(): Response {
-		//Set section
-		$section = $this->translator->trans('Organizer regulation');
+	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');
@@ -311,23 +301,29 @@ class DefaultController {
 			$this->translator->trans('Libre Air')
 		];
 
-		//Set title
-		$title = $this->translator->trans($this->config['site']['title']).' - '.$section;
-
 		//Render template
-		return $this->render('@RapsysAir/default/organizer_regulation.html.twig', ['title' => $title, 'section' => $section]+$this->context);
+		$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;
 	}
 
 	/**
 	 * The terms of service page
 	 *
-	 * @desc Display the terms of service policy
+	 * Display the terms of service policy
 	 *
+	 * @param Request $request The request instance
 	 * @return Response The rendered view
 	 */
-	public function termsOfService(): Response {
-		//Set section
-		$section = $this->translator->trans('Terms of service');
+	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');
@@ -338,23 +334,29 @@ class DefaultController {
 			$this->translator->trans('Libre Air')
 		];
 
-		//Set title
-		$title = $this->translator->trans($this->config['site']['title']).' - '.$section;
-
 		//Render template
-		return $this->render('@RapsysAir/default/terms_of_service.html.twig', ['title' => $title, 'section' => $section]+$this->context);
+		$response = $this->render('@RapsysAir/default/terms_of_service.html.twig', $this->context);
+
+		//Set as cachable
+		$response->setEtag(md5($response->getContent()));
+		$response->setPublic();
+		$response->isNotModified($request);
+
+		//Return response
+		return $response;
 	}
 
 	/**
 	 * The frequently asked questions page
 	 *
-	 * @desc Display the frequently asked questions
+	 * Display the frequently asked questions
 	 *
+	 * @param Request $request The request instance
 	 * @return Response The rendered view
 	 */
-	public function frequentlyAskedQuestions(): Response {
-		//Set section
-		$section = $this->translator->trans('Frequently asked questions');
+	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');
@@ -366,68 +368,407 @@ class DefaultController {
 			$this->translator->trans('Libre Air')
 		];
 
-		//Set title
-		$title = $this->translator->trans($this->config['site']['title']).' - '.$section;
-
 		//Render template
-		return $this->render('@RapsysAir/default/frequently_asked_questions.html.twig', ['title' => $title, 'section' => $section]+$this->context);
+		$response = $this->render('@RapsysAir/default/frequently_asked_questions.html.twig', $this->context);
+
+		//Set as cachable
+		$response->setEtag(md5($response->getContent()));
+		$response->setPublic();
+		$response->isNotModified($request);
+
+		//Return response
+		return $response;
 	}
 
 	/**
-	 * Return the bundle alias
+	 * List all users
 	 *
-	 * {@inheritdoc}
+	 * Display all user with a group listed as users
+	 *
+	 * @param Request $request The request instance
+	 *
+	 * @return Response The rendered view
 	 */
-	public function getAlias(): string {
-		return 'rapsys_air';
+	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');
+		}
+
+		//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')];
+		}
+
+		//Render the view
+		return $this->render('@RapsysAir/user/index.html.twig', $this->context);
 	}
 
 	/**
-	 * Renders a view
+	 * List all sessions for the user
+	 *
+	 * Display all sessions for the user with an application or login form
+	 *
+	 * @param Request $request The request instance
+	 * @param int $id The user id
 	 *
-	 * {@inheritdoc}
+	 * @return Response The rendered view
 	 */
-	protected function render(string $view, array $parameters = [], Response $response = null): Response {
-		//Create application form for role_guest
-		if ($this->isGranted('ROLE_GUEST')) {
-			//Without application form
-			if (empty($parameters['forms']['application'])) {
-				//Fetch doctrine
-				$doctrine = $this->getDoctrine();
-
-				//Create ApplicationType form
-				$application = $this->createForm('Rapsys\AirBundle\Form\ApplicationType', null, [
-					//Set the action
-					'action' => $this->generateUrl('rapsys_air_application_add'),
-					//Set the form attribute
-					'attr' => [ 'class' => 'col' ],
-					//Set admin
-					'admin' => $this->isGranted('ROLE_ADMIN'),
-					//Set default user to current
-					'user' => $this->getUser()->getId(),
-					//Set default slot to evening
-					//XXX: default to Evening (3)
-					'slot' => $doctrine->getRepository(Slot::class)->findOneById(3)
-				]);
+	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]));
+		}
+
+		//Create token
+		$token = new AnonymousToken($this->context['user']['roles']);
+
+		//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]));
+		}
+
+		//With invalid user slug
+		if ($this->context['user']['slug'] !== $user) {
+			//Redirect to cleaned url
+			return $this->redirectToRoute('rapsysair_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+		}
+
+		//Fetch calendar
+		$this->context['calendar'] = $this->doctrine->getRepository(Session::class)->findAllByPeriodAsCalendarArray($this->period, !$this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED'), null, null, $id);
+
+		//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'];
+
+				//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'));
+
+			//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;
+			}
+		}
+
+		//Add multi map
+		$this->context['multimap'] = $this->map->getMultiMap($this->context['user']['multimap'], $this->modified->getTimestamp(), $this->context['locations']);
+
+		//Set keywords
+		$this->context['keywords'] = [
+			$this->context['user']['pseudonym'],
+			$this->translator->trans('calendar'),
+			$this->translator->trans('Libre Air')
+		];
+
+		//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
+		$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 (isset($snippets[$location['id']])) {
+					//Set existing in current
+					$current = $snippets[$location['id']];
+				//Without existing snippet
+				} else {
+					//Init snippet
+					$current = new Snippet($this->locale, $this->doctrine->getRepository(Location::class)->findOneById($location['id']), $user);
+				}
+
+				//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);
+
+				//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('rapsysair_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+				}
 
 				//Add form to context
-				$parameters['forms']['application'] = $application->createView();
+				$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('rapsysair_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('rapsysair_user_view', ['id' => $id, 'user' => $this->context['user']['slug']]);
+					}
+				}
+
+				//Add form to context
+				$this->context['forms']['images'][$locationId] = $form->createView();
 			}
-		//Create login form for anonymous
-		} elseif (!$this->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
-			//Create ApplicationType form
-			$login = $this->createForm('Rapsys\UserBundle\Form\LoginType', null, [
-				//Set the action
-				'action' => $this->generateUrl('rapsys_user_login'),
-				//Set the form attribute
-				'attr' => [ 'class' => 'col' ]
-			]);
-
-			//Add form to context
-			$parameters['forms']['login'] = $login->createView();
 		}
 
-		//Call parent method
-		return $this->_render($view, $parameters, $response);
+		//Render the view
+		return $this->render('@RapsysAir/user/view.html.twig', ['id' => $id]+$this->context);
 	}
 }