Symfony 6 / Sonata 5: Liên kết ngôn ngữ và múi giờ với sở thích người dùng.

Chúng tôi đã thấy trong một bài viết trước cách cài đặt Symfony 6 + Sonata Admin 5 với quản lý người dùng và quản lý media.
Chúng tôi sẽ tiếp tục cài đặt này với cấu hình ngôn ngữ và múi giờ.


Chúng tôi sẽ cài đặt intlBundle giúp quản lý hiển thị theo địa phương.

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

Chúng tôi nhân cơ hội này để thêm trường múi giờ và ngôn ngữ vào người dùng.
Để làm điều này, chúng tôi sẽ mở rộng userBundle và thêm trường vào trong admin.

Chúng tôi mở rộng người dùng bằng cách di chuyển entity người dùng đến: src/Application/Sonata/UserBundle/Entity/User.php
Và chúng tôi thêm vào đó trường múi giờ và ngôn ngữ.

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

Chúng tôi nhân bản admin của Sonata và đặt nó tại src/Application/Sonata/UserBundle/Admin/Model/UserAdmin.php
Chúng tôi thêm trường múi giờ của chúng tôi vào đó.

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

Chúng tôi thay đổi tham chiếu của entity và admin của chúng tôi trong 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

và chúng tôi đăng ký extension của chúng tôi trong 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

Chúng tôi cập nhật sơ đồ của chúng tôi và thử nghiệm.

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

Trường mới của chúng tôi đã được tính đến.

image-5

Chúng tôi thiết lập ngôn ngữ mặc định là FR

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

Chúng tôi cấu hình 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
            

Bây giờ chúng tôi muốn rằng cài đặt múi giờ và ngôn ngữ mặc định của người dùng chúng tôi được tính đến.
Để làm điều này, chúng tôi sẽ lấy cài đặt của họ vào thời điểm đăng nhập và đặt chúng vào session.
Sau đó, chúng tôi sẽ lấy thông tin này mỗi khi tải trang và ghi đè giao diện với các cài đặt này.

Bước đầu tiên, chúng tôi lấy thông tin khi đăng nhập. Để làm điều này, chúng tôi sẽ chỉ định dịch vụ để sử dụng trong handler_success trong security.yaml.

Chúng tôi thiết lập 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

Sau đó chúng tôi thêm dịch vụ của chúng tôi trong 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

Và chúng tôi tạo Handler của chúng tôi trong 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));
    }

}

Ở giai đoạn này, chúng tôi có các sở thích trong session vào thời điểm đăng nhập. Bây giờ chúng tôi thêm 2 Listener. LocaleListener và TimezoneListener.
Chúng tôi thêm chúng vào các dịch vụ.

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 }   

Và chúng tôi tạo Listener của chúng tôi.

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

}

Chúng tôi chỉ còn lại việc kiểm tra xem nó đã được tính đến chưa.
Đầu tiên chúng tôi thiết lập sở thích của chúng tôi ở ngôn ngữ English và múi giờ ở phía bên kia thế giới.

Chúng tôi không có bản dịch tiếng Việt, vì vậy chúng tôi chuyển sang EN. Tuy nhiên, chúng tôi có danh sách các ngôn ngữ đã dịch.

Giao diện bằng tiếng Anh và các ngày được dịch sang tiếng Việt, và thời gian phù hợp với múi giờ HCMC.

Giờ chúng tôi chuyển trở lại FR và Europe/Paris.

Giao diện của chúng tôi bằng tiếng Pháp và các hộp danh sách cũng vậy.

Và ngày tháng của chúng tôi được viết theo định dạng FR và thời gian được thiết lập theo Paris. 11:24 tại Paris so với 5:24 chiều tại Việt Nam.