Symfony 6 / Sonata 5: Collega le località e il fuso orario alle preferenze dell'utente.

Abbiamo visto in un articolo precedente come installare Symfony 6 + Sonata Admin 5 con gestione degli utenti e dei media.
Continueremo questa installazione con la configurazione delle lingue e dei fusi orari.


Installeremo intlBundle che permette di gestire la visualizzazione localizzata.

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

Approfittiamo di questa occasione per aggiungere un campo fuso orario e locale all'utente.
Per fare ciò, estenderemo userBundle e aggiungeremo un campo nell'admin.

Estendiamo user spostando l'entità utente in: src/Application/Sonata/UserBundle/Entity/User.php
E vi aggiungiamo un campo fuso orario e un campo locale.

<?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;
    }
}

Duplichiamo l'admin di Sonata e lo collochiamo in src/Application/Sonata/UserBundle/Admin/Model/UserAdmin.php
Aggiungiamo al suo interno il nostro campo fuso orario.

<?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);
        });
    }
}

Modifichiamo i riferimenti della nostra entità e del nostro admin in 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

e registriamo la nostra estensione in 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

Aggiorniamo il nostro schema e facciamo un test.

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

Il nostro nuovo campo è correttamente preso in considerazione.

image-5

Impostiamo il locale predefinito su FR

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

Configuriamo 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
            

Adesso desideriamo che le impostazioni di fuso orario e lingua predefinita del nostro utente siano prese in considerazione.
Per fare ciò, recupereremo le sue impostazioni al momento del login e le metteremo nella sessione.
Quindi, recupereremo queste informazioni ad ogni caricamento della pagina e sovraccaricheremo l'interfaccia con queste impostazioni.

Come primo passo, otteniamo le informazioni al login. Per fare ciò, specificheremo il servizio da utilizzare durante l'handler_success in security.yaml.

Impostiamo 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

Quindi aggiungiamo il nostro servizio in 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

E creiamo il nostro Handler in 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));
    }

}

A questo punto, abbiamo le preferenze nella sessione al momento del login. Ora aggiungiamo 2 Listeners. LocaleListener e TimezoneListener.
Li aggiungiamo ai servizi.

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 }   

E creiamo i nostri 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)),
        );
    }

}

Non ci resta che verificare che sia stato preso in considerazione.
Prima impostiamo le nostre preferenze in locale inglese e fuso orario dall'altra parte del mondo.

Non abbiamo una traduzione in vietnamita, quindi ricorriamo all'EN. Tuttavia, abbiamo la lista delle località tradotte.

L'interfaccia è in inglese e le date sono tradotte in vietnamita, e l'orario corrisponde al fuso orario di HCMC.

Adesso torniamo a FR e Europe/Paris.

La nostra interfaccia è in francese e anche le caselle di selezione.

E le nostre date sono scritte nel formato FR e l'orario è impostato su Parigi. Le 11:24 a Parigi contro le 17:24 in Vietnam.