Symfony 6 / Sonata 5: Vincular las configuraciones regionales y la zona horaria a las preferencias del usuario.

En un artículo anterior vimos cómo instalar Symfony 6 + Sonata Admin 5 con gestión de usuarios y medios.
Continuaremos con esta instalación con la configuración de idiomas y zonas horarias.


Instalaremos intlBundle que permite gestionar la visualización localizada.

php8.1 composer.phar require sonata-project/intl-bundle

Aprovechamos esta oportunidad para agregar un campo de zona horaria y localidad al usuario.
Para hacer esto, extenderemos userBundle y agregaremos un campo en el admin.

Extendemos el usuario moviendo la entidad de usuario a: src/Application/Sonata/UserBundle/Entity/User.php
Y le agregamos un campo de zona horaria y un campo localidad.

<?php 
namespace App\Application\Sonata\UserBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Sonata\UserBundle\Entity\BaseUser;

/**
 * @ORM\Entity
 * @ORM\Table(name="user__user")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    protected $id;
    
    
    /**
     * @var string|null
     *
     * @ORM\Column(name="timezone", type="string", length=64, nullable=true)
     */
     protected $timezone;
    
    /**
     * @var string|null
     *
     * @ORM\Column(name="locale", type="string", length=8, nullable=true)
     */
    protected $locale;
    

    public function getTimezone(){
        return $this->timezone;
    }
    
    public function setTimezone($timezone){
        $this->timezone=$timezone;
        return $this;
    }
    
    public function getLocale(){
        return $this->locale;
    }
    
    public function setLocale($locale){
        $this->locale=$locale;
        return $this;
    }
}

Duplicamos el admin de Sonata y lo colocamos en src/Application/Sonata/UserBundle/Admin/Model/UserAdmin.php
Agregamos a este nuestro campo de zona horaria.

<?php

declare(strict_types=1);

/*
 * This file is part of the Sonata Project package.
 *
 * (c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace App\Application\Sonata\UserBundle\Admin\Model;

use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\FieldDescription\FieldDescriptionInterface;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;
use Sonata\UserBundle\Form\Type\RolesMatrixType;
use Sonata\UserBundle\Model\UserManagerInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;

/**
 * @phpstan-extends AbstractAdmin<\Sonata\UserBundle\Model\UserInterface>
 */
class UserAdmin extends AbstractAdmin
{
    protected UserManagerInterface $userManager;

    public function __construct(UserManagerInterface $userManager)
    {
        parent::__construct();
        $this->userManager = $userManager;
    }

    protected function preUpdate(object $object): void
    {
        $this->userManager->updatePassword($object);
    }

    protected function configureFormOptions(array &$formOptions): void
    {
        $formOptions['validation_groups'] = ['Default'];

        if (!$this->hasSubject() || null === $this->getSubject()->getId()) {
            $formOptions['validation_groups'][] = 'Registration';
        } else {
            $formOptions['validation_groups'][] = 'Profile';
        }
    }

    protected function configureListFields(ListMapper $list): void
    {
        $list
            ->addIdentifier('username')
            ->add('email')
            ->add('enabled', null, ['editable' => true])
            ->add('createdAt');

        if ($this->isGranted('ROLE_ALLOWED_TO_SWITCH')) {
            $list
                ->add('impersonating', FieldDescriptionInterface::TYPE_STRING, [
                    'virtual_field' => true,
                    'template' => '@SonataUser/Admin/Field/impersonating.html.twig',
                ]);
        }
    }

    protected function configureDatagridFilters(DatagridMapper $filter): void
    {
        $filter
            ->add('id')
            ->add('username')
            ->add('email');
    }

    protected function configureShowFields(ShowMapper $show): void
    {
        $show
            ->add('username')
            ->add('email');
    }

    protected function configureFormFields(FormMapper $form): void
    {
        $form
            ->with('general', ['class' => 'col-md-4'])
                ->add('username')
                ->add('email')
                ->add('locale', LocaleType::class, ['required' => false])
                ->add('timezone', TimezoneType::class, ['required' => false])
                ->add('plainPassword', TextType::class, [
                    'required' => (!$this->hasSubject() || null === $this->getSubject()->getId()),
                ])
                ->add('enabled', null)
            ->end()
            ->with('roles', ['class' => 'col-md-8'])
                ->add('realRoles', RolesMatrixType::class, [
                    'label' => false,
                    'multiple' => true,
                    'required' => false,
                ])
            ->end();
    }

    protected function configureExportFields(): array
    {
        // Avoid sensitive properties to be exported.
        return array_filter(parent::configureExportFields(), static function (string $v): bool {
            return !\in_array($v, ['password', 'salt'], true);
        });
    }
}

Cambiamos las referencias de nuestra entidad y nuestro admin en sonata_user.yml

sonata_user:
    class:
        user: App\Application\Sonata\UserBundle\Entity\User
    admin:
        user:
            class: App\Application\Sonata\UserBundle\Admin\Model\UserAdmin
    resetting:
        email:
            address: "test@test.com"
            sender_name: Backoffice
            
    security_acl: true
    manager_type: orm # can be orm or mongodb

y registramos nuestra extensión en doctrine.yml

doctrine:
    dbal:
        url: '%env(resolve:DATABASE_URL)%'

        # IMPORTANT: You MUST configure your server version,
        # either here or in the DATABASE_URL env var (see .env file)
        #server_version: '13'
    orm:
        auto_generate_proxy_classes: true
        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
        auto_mapping: true
        mappings:
            App:
                is_bundle: false
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'
                alias: App

            App\Application\Sonata\MediaBundle:
                is_bundle: false
                dir: '%kernel.project_dir%/src/Application/Sonata/MediaBundle/Entity'
                prefix: 'App\Application\Sonata\MediaBundle\Entity'
                alias: App\Application\Sonata\MediaBundle

            App\Application\Sonata\UserBundle:
                is_bundle: false
                dir: '%kernel.project_dir%/src/Application/Sonata/UserBundle/Entity'
                prefix: 'App\Application\Sonata\UserBundle\Entity'
                alias: App

Actualizamos nuestro esquema y hacemos una prueba.

php8.1 bin/console doctrine:schema:update --force

Nuestro nuevo campo está bien considerado.

image-5

Configuramos la localidad predeterminada en FR

#config/packages/translation.yaml
framework:
    default_locale: fr
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks:
            - en
            - fr

Configuramos sonata_intl

sonata_intl:
    timezone:
        default: Europe/Paris
        locales:
            fr:    Europe/Paris
            en_UK: Europe/London
        detectors:
            - sonata.intl.timezone_detector.user
            - sonata.intl.timezone_detector.locale
            

Ahora deseamos que la zona horaria y los ajustes de idioma predeterminados de nuestro usuario sean tenidos en cuenta.
Para hacer esto, recuperaremos sus ajustes en el momento de iniciar sesión y los colocaremos en sesión.
Luego, recuperaremos esta información en cada carga de página y sobrecargaremos la interfaz con estos ajustes.

Primer paso, obtenemos la información al iniciar sesión. Para hacer esto, especificaremos el servicio a usar durante handler_success en security.yaml.

Configuramos success_handler: login_success_handler

security:
    enable_authenticator_manager: true
    password_hashers:
        Sonata\UserBundle\Model\UserInterface:
            algorithm: auto
    providers:
        sonata_user_bundle:
            id: sonata.user.security.user_provider
    access_decision_manager:
        strategy: unanimous   
    
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        admin:
            lazy: true
            pattern: /admin(.*)
            provider: sonata_user_bundle
            context: user
            switch_user: true
            form_login:
                login_path: sonata_user_admin_security_login
                check_path: sonata_user_admin_security_check
                default_target_path: sonata_admin_dashboard
                success_handler: login_success_handler
            logout:
                path: sonata_user_admin_security_logout
                target: sonata_user_admin_security_login
            remember_me:
                #secret: "%env(APP_SECRET)%"
                secret: "123456"
                lifetime: 2629746
                path: /admin


    
    access_control:
        - { path: ^/admin/login$, role: PUBLIC_ACCESS }
        - { path: ^/admin/logout$, role: PUBLIC_ACCESS }
        - { path: ^/admin/login_check$, role: PUBLIC_ACCESS }
        - { path: ^/admin/request$, role: PUBLIC_ACCESS }
        - { path: ^/admin/check-email$, role: PUBLIC_ACCESS }
        - { path: ^/admin/reset/.*$, role: PUBLIC_ACCESS }
        - { path: ^/admin/, role: ROLE_ADMIN }    
    
    role_hierarchy:
        ROLE_ADMIN:
            - ROLE_USER
            - ROLE_SONATA_ADMIN
            - ROLE_SONATA_USER_ADMIN_USER_VIEW
        ROLE_SUPER_ADMIN:
            - ROLE_ADMIN
            - ROLE_ALLOWED_TO_SWITCH        

when@test:
    security:
        password_hashers:
            # By default, password hashers are resource intensive and take time. This is
            # important to generate secure password hashes. In tests however, secure hashes
            # are not important, waste resources and increase test times. The following
            # reduces the work factor to the lowest possible values.
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
                algorithm: auto
                cost: 4 # Lowest possible value for bcrypt
                time_cost: 3 # Lowest possible value for argon
                memory_cost: 10 # Lowest possible value for argon

Luego añadimos nuestro servicio en service.yaml

services:

    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'


    login_success_handler:
        parent: security.authentication.success_handler
        class:  App\EventListener\LoginSuccessHandler

Y creamos nuestro Handler en src/EventListener/LoginSuccessHandler.php

<?php 
namespace App\EventListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\HttpUtils;
use Symfony\Component\Security\Http\Authentication\DefaultAuthenticationSuccessHandler;

class LoginSuccessHandler extends DefaultAuthenticationSuccessHandler
{
    
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
    {
        $locale = $token->getUser()->getLocale();
        $request->getSession()->set('_locale', $locale);
        $request->setLocale($locale);
        
        $timezone = $token->getUser()->getTimezone();
        $request->getSession()->set('_timezone', $timezone);
        
        return $this->httpUtils->createRedirectResponse($request, $this->determineTargetUrl($request));
    }

}

En esta etapa, tenemos las preferencias en sesión en el momento de iniciar sesión. Ahora añadimos 2 Listeners. LocaleListener y TimezoneListener.
Añadimos estos a los servicios.

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.


    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones

    

    login_success_handler:
        parent: security.authentication.success_handler
        class:  App\EventListener\LoginSuccessHandler

    App\EventListener\LocaleListener:
        class: App\EventListener\LocaleListener
        arguments: [ "%kernel.default_locale%"]
        tags:
            - { name: kernel.event_subscriber }    
            
    App\EventListener\TimeZoneListener:
        class: App\EventListener\TimeZoneListener
        arguments: ["@twig"]
        tags:
            - { name: kernel.event_subscriber }   

Y creamos nuestros listeners.

<?php 
#src/EventListener/LocaleListener.php
namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class LocaleListener implements EventSubscriberInterface
{
    private $defaultLocale;

    public function __construct($defaultLocale = 'fr')
    {
        $this->defaultLocale = $defaultLocale;
    }

    public function onKernelRequest(RequestEvent $event)
    {

        $request = $event->getRequest();
        if (!$request->hasPreviousSession()) {
            return;
        }
        $request->setLocale($request->getSession()->get('_locale', $this->defaultLocale));
    }

    public static function getSubscribedEvents()
    {
        return array(
            KernelEvents::REQUEST => array(array('onKernelRequest', 17)),
        );
    }
}

<?php
#src/EventListener/TimeZoneListener.php
namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Twig\Environment;
use Twig\Extension\CoreExtension;

/**
 * Class TimeZoneListener
 *
 * @package App\EventListener
 */
class TimeZoneListener implements EventSubscriberInterface
{
    /** @var twig */
    private $twig;

    public function __construct( $twig)
    {
        $this->twig     = $twig;
        
    }

    public function onKernelRequest( RequestEvent $request)
    {
        $request = $request->getRequest();
        $timezone = $request->getSession()->get('_timezone', "Europe/Paris");

        $core = $this->twig->getExtension('Twig\Extension\CoreExtension');
        $core->setTimezone($timezone);
        
        return $core;
        
    }

    public static function getSubscribedEvents()
    {
        return array(
            KernelEvents::REQUEST => array(array('onKernelRequest', 18)),
        );
    }

}

Solo nos queda comprobar que se haya tenido en cuenta.
Primero establecemos nuestras preferencias en localidad inglés y zona horaria al otro lado del mundo.

No tenemos una traducción al vietnamita, así que recurrimos al inglés. Sin embargo, tenemos la lista de localidades traducidas.

La interfaz está en inglés y las fechas están traducidas al vietnamita, y la hora coincide con la zona horaria de HCMC.

Ahora volvemos a FR y Europe/Paris.

Nuestra interfaz está en francés y también lo están las listas desplegables.

Y nuestras fechas están escritas en el formato FR y la hora se establece en París. 11:24 en París vs 5:24 p.m. en Vietnam.