mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-08-20 14:43:49 +00:00
Refactor PersonSearch and create PersonACLAwareRepository
The search api delegates the query to a person acl aware "repository" (although this does not implements ObjectRepository interface).
This commit is contained in:
@@ -20,71 +20,39 @@
|
||||
namespace Chill\PersonBundle\Search;
|
||||
|
||||
use Chill\MainBundle\Search\AbstractSearch;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\MainBundle\Search\SearchInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
|
||||
use Chill\MainBundle\Search\ParsingException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
|
||||
use Symfony\Component\Security\Core\Role\Role;
|
||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Chill\MainBundle\Form\Type\ChillDateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Chill\MainBundle\Search\HasAdvancedSearchFormInterface;
|
||||
use Doctrine\ORM\Query;
|
||||
use Symfony\Component\Templating\EngineInterface;
|
||||
|
||||
class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
|
||||
HasAdvancedSearchFormInterface
|
||||
class PersonSearch extends AbstractSearch implements HasAdvancedSearchFormInterface
|
||||
{
|
||||
use ContainerAwareTrait;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $em;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var \Chill\MainBundle\Entity\User
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var AuthorizationHelper
|
||||
*/
|
||||
private $helper;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var PaginatorFactory
|
||||
*/
|
||||
protected $paginatorFactory;
|
||||
protected EngineInterface $templating;
|
||||
protected PaginatorFactory $paginatorFactory;
|
||||
protected PersonACLAwareRepositoryInterface $personACLAwareRepository;
|
||||
|
||||
const NAME = "person_regular";
|
||||
|
||||
private const POSSIBLE_KEYS = [
|
||||
'_default', 'firstname', 'lastname', 'birthdate', 'birthdate-before',
|
||||
'birthdate-after', 'gender', 'nationality'
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
TokenStorageInterface $tokenStorage,
|
||||
AuthorizationHelper $helper,
|
||||
PaginatorFactory $paginatorFactory)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->user = $tokenStorage->getToken()->getUser();
|
||||
$this->helper = $helper;
|
||||
EngineInterface $templating,
|
||||
PaginatorFactory $paginatorFactory,
|
||||
PersonACLAwareRepositoryInterface $personACLAwareRepository
|
||||
) {
|
||||
$this->templating = $templating;
|
||||
$this->paginatorFactory = $paginatorFactory;
|
||||
|
||||
// throw an error if user is not a valid user
|
||||
if (!$this->user instanceof \Chill\MainBundle\Entity\User) {
|
||||
throw new \LogicException('The user provided must be an instance'
|
||||
. ' of Chill\MainBundle\Entity\User');
|
||||
}
|
||||
$this->personACLAwareRepository = $personACLAwareRepository;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -120,12 +88,14 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
|
||||
$paginator = $this->paginatorFactory->create($total);
|
||||
|
||||
if ($format === 'html') {
|
||||
return $this->container->get('templating')->render('@ChillPerson/Person/list_with_period.html.twig',
|
||||
return $this->templating->render('@ChillPerson/Person/list_with_period.html.twig',
|
||||
array(
|
||||
'persons' => $this->search($terms, $start, $limit, $options),
|
||||
'pattern' => $this->recomposePattern($terms, array('nationality',
|
||||
'firstname', 'lastname', 'birthdate', 'gender',
|
||||
'birthdate-before','birthdate-after'), $terms['_domain']),
|
||||
'pattern' => $this->recomposePattern(
|
||||
$terms,
|
||||
\array_filter(self::POSSIBLE_KEYS, fn($item) => $item !== '_default'),
|
||||
$terms['_domain']
|
||||
),
|
||||
'total' => $total,
|
||||
'start' => $start,
|
||||
'search_name' => self::NAME,
|
||||
@@ -152,158 +122,81 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
|
||||
*/
|
||||
protected function search(array $terms, $start, $limit, array $options = array())
|
||||
{
|
||||
$qb = $this->createQuery($terms, 'search');
|
||||
[
|
||||
'_default' => $default,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'birthdate' => $birthdate,
|
||||
'birthdate-before' => $birthdateBefore,
|
||||
'birthdate-after' => $birthdateAfter,
|
||||
'gender' => $gender,
|
||||
'nationality' => $countryCode,
|
||||
|
||||
if ($options['simplify'] ?? false) {
|
||||
$qb->select(
|
||||
'p.id',
|
||||
$qb->expr()->concat(
|
||||
'p.firstName',
|
||||
$qb->expr()->literal(' '),
|
||||
'p.lastName'
|
||||
).'AS text'
|
||||
);
|
||||
} else {
|
||||
$qb->select('p');
|
||||
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
|
||||
|
||||
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
|
||||
if (NULL !== ${$v}) {
|
||||
try {
|
||||
${$v} = new \DateTime(${$v});
|
||||
} catch (\Exception $e) {
|
||||
throw new ParsingException('The date is '
|
||||
. 'not parsable', 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$qb
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($start);
|
||||
|
||||
//order by firstname, lastname
|
||||
|
||||
$qb
|
||||
->orderBy('p.firstName')
|
||||
->addOrderBy('p.lastName');
|
||||
|
||||
if ($options['simplify'] ?? false) {
|
||||
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);
|
||||
} else {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
return $this->personACLAwareRepository
|
||||
->findBySearchCriteria(
|
||||
$start,
|
||||
$limit,
|
||||
$options['simplify'] ?? false,
|
||||
$default,
|
||||
$firstname,
|
||||
$lastname,
|
||||
$birthdate,
|
||||
$birthdateBefore,
|
||||
$birthdateAfter,
|
||||
$gender,
|
||||
$countryCode,
|
||||
);
|
||||
}
|
||||
|
||||
protected function count(array $terms)
|
||||
protected function count(array $terms): int
|
||||
{
|
||||
$qb = $this->createQuery($terms);
|
||||
[
|
||||
'_default' => $default,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'birthdate' => $birthdate,
|
||||
'birthdate-before' => $birthdateBefore,
|
||||
'birthdate-after' => $birthdateAfter,
|
||||
'gender' => $gender,
|
||||
'nationality' => $countryCode,
|
||||
|
||||
$qb->select('COUNT(p.id)');
|
||||
] = $terms + \array_fill_keys(self::POSSIBLE_KEYS, null);
|
||||
|
||||
return $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
|
||||
private $_cacheQuery = array();
|
||||
|
||||
/**
|
||||
*
|
||||
* @param array $terms
|
||||
* @return \Doctrine\ORM\QueryBuilder
|
||||
*/
|
||||
public function createQuery(array $terms)
|
||||
{
|
||||
//get from cache
|
||||
$cacheKey = md5(serialize($terms));
|
||||
if (array_key_exists($cacheKey, $this->_cacheQuery)) {
|
||||
return clone $this->_cacheQuery[$cacheKey];
|
||||
}
|
||||
|
||||
$qb = $this->em->createQueryBuilder();
|
||||
|
||||
$qb->from('ChillPersonBundle:Person', 'p');
|
||||
|
||||
if (array_key_exists('firstname', $terms)) {
|
||||
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.firstName))', ':firstname'))
|
||||
->setParameter('firstname', '%'.$terms['firstname'].'%');
|
||||
}
|
||||
|
||||
if (array_key_exists('lastname', $terms)) {
|
||||
$qb->andWhere($qb->expr()->like('UNACCENT(LOWER(p.lastName))', ':lastname'))
|
||||
->setParameter('lastname', '%'.$terms['lastname'].'%');
|
||||
}
|
||||
|
||||
foreach (['birthdate', 'birthdate-before', 'birthdate-after'] as $key)
|
||||
if (array_key_exists($key, $terms)) {
|
||||
try {
|
||||
$date = new \DateTime($terms[$key]);
|
||||
} catch (\Exception $ex) {
|
||||
throw new ParsingException('The date is '
|
||||
. 'not parsable', 0, $ex);
|
||||
}
|
||||
|
||||
switch($key) {
|
||||
case 'birthdate':
|
||||
$qb->andWhere($qb->expr()->eq('p.birthdate', ':birthdate'))
|
||||
->setParameter('birthdate', $date);
|
||||
break;
|
||||
case 'birthdate-before':
|
||||
$qb->andWhere($qb->expr()->lt('p.birthdate', ':birthdatebefore'))
|
||||
->setParameter('birthdatebefore', $date);
|
||||
break;
|
||||
case 'birthdate-after':
|
||||
$qb->andWhere($qb->expr()->gt('p.birthdate', ':birthdateafter'))
|
||||
->setParameter('birthdateafter', $date);
|
||||
break;
|
||||
default:
|
||||
throw new \LogicException("this case $key should not exists");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (array_key_exists('gender', $terms)) {
|
||||
if (!in_array($terms['gender'], array(Person::MALE_GENDER, Person::FEMALE_GENDER))) {
|
||||
throw new ParsingException('The gender '
|
||||
.$terms['gender'].' is not accepted. Should be "'.Person::MALE_GENDER
|
||||
.'" or "'.Person::FEMALE_GENDER.'"');
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()->eq('p.gender', ':gender'))
|
||||
->setParameter('gender', $terms['gender']);
|
||||
}
|
||||
|
||||
if (array_key_exists('nationality', $terms)) {
|
||||
try {
|
||||
$country = $this->em->createQuery('SELECT c FROM '
|
||||
. 'ChillMainBundle:Country c WHERE '
|
||||
. 'LOWER(c.countryCode) LIKE :code')
|
||||
->setParameter('code', $terms['nationality'])
|
||||
->getSingleResult();
|
||||
} catch (\Doctrine\ORM\NoResultException $ex) {
|
||||
throw new ParsingException('The country code "'.$terms['nationality'].'" '
|
||||
. ', used in nationality, is unknow', 0, $ex);
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()->eq('p.nationality', ':nationality'))
|
||||
->setParameter('nationality', $country);
|
||||
}
|
||||
|
||||
if ($terms['_default'] !== '') {
|
||||
$grams = explode(' ', $terms['_default']);
|
||||
|
||||
foreach($grams as $key => $gram) {
|
||||
$qb->andWhere($qb->expr()
|
||||
->like('p.fullnameCanonical', 'UNACCENT(LOWER(:default_'.$key.'))'))
|
||||
->setParameter('default_'.$key, '%'.$gram.'%');
|
||||
foreach (['birthdateBefore', 'birthdateAfter', 'birthdate'] as $v) {
|
||||
if (NULL !== ${$v}) {
|
||||
try {
|
||||
${$v} = new \DateTime(${$v});
|
||||
} catch (\Exception $e) {
|
||||
throw new ParsingException('The date is '
|
||||
. 'not parsable', 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//restraint center for security
|
||||
$reachableCenters = $this->helper->getReachableCenters($this->user,
|
||||
new Role('CHILL_PERSON_SEE'));
|
||||
$qb->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()
|
||||
->in('p.center', ':centers'),
|
||||
$qb->expr()
|
||||
->isNull('p.center')
|
||||
)
|
||||
);
|
||||
$qb->setParameter('centers', $reachableCenters);
|
||||
|
||||
$this->_cacheQuery[$cacheKey] = $qb;
|
||||
|
||||
return clone $qb;
|
||||
return $this->personACLAwareRepository
|
||||
->countBySearchCriteria(
|
||||
$default,
|
||||
$firstname,
|
||||
$lastname,
|
||||
$birthdate,
|
||||
$birthdateBefore,
|
||||
$birthdateAfter,
|
||||
$gender,
|
||||
$countryCode,
|
||||
);
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder)
|
||||
@@ -396,4 +289,10 @@ class PersonSearch extends AbstractSearch implements ContainerAwareInterface,
|
||||
return 'Search within persons';
|
||||
}
|
||||
|
||||
public static function getAlias(): string
|
||||
{
|
||||
return self::NAME;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@@ -2,90 +2,32 @@
|
||||
|
||||
namespace Chill\PersonBundle\Search;
|
||||
|
||||
|
||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||
use Chill\MainBundle\Search\AbstractSearch;
|
||||
use Chill\MainBundle\Search\SearchInterface;
|
||||
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Role\Role;
|
||||
use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface;
|
||||
use Symfony\Component\Templating\EngineInterface;
|
||||
|
||||
/**
|
||||
* Class SimilarityPersonSearch
|
||||
*
|
||||
* @package Chill\PersonBundle\Search
|
||||
*/
|
||||
class SimilarityPersonSearch extends AbstractSearch
|
||||
{
|
||||
|
||||
use ContainerAwareTrait;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $em;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var \Chill\MainBundle\Entity\User
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var AuthorizationHelper
|
||||
*/
|
||||
private $helper;
|
||||
|
||||
/**
|
||||
*
|
||||
* @var PaginatorFactory
|
||||
*/
|
||||
protected $paginatorFactory;
|
||||
|
||||
protected PaginatorFactory $paginatorFactory;
|
||||
|
||||
private PersonACLAwareRepositoryInterface $personACLAwareRepository;
|
||||
|
||||
private EngineInterface $templating;
|
||||
|
||||
const NAME = "person_similarity";
|
||||
|
||||
/**
|
||||
*
|
||||
* @var PersonSearch
|
||||
*/
|
||||
private $personSearch;
|
||||
|
||||
|
||||
/**
|
||||
* SimilarityPersonSearch constructor.
|
||||
*
|
||||
* @param EntityManagerInterface $em
|
||||
* @param TokenStorageInterface $tokenStorage
|
||||
* @param AuthorizationHelper $helper
|
||||
* @param PaginatorFactory $paginatorFactory
|
||||
* @param PersonSearch $personSearch
|
||||
*/
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
TokenStorageInterface $tokenStorage,
|
||||
AuthorizationHelper $helper,
|
||||
PaginatorFactory $paginatorFactory,
|
||||
PersonSearch $personSearch)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->user = $tokenStorage->getToken()->getUser();
|
||||
$this->helper = $helper;
|
||||
PersonACLAwareRepositoryInterface $personACLAwareRepository,
|
||||
EngineInterface $templating
|
||||
) {
|
||||
$this->paginatorFactory = $paginatorFactory;
|
||||
$this->personSearch = $personSearch;
|
||||
|
||||
// throw an error if user is not a valid user
|
||||
if (!$this->user instanceof \Chill\MainBundle\Entity\User) {
|
||||
throw new \LogicException('The user provided must be an instance'
|
||||
. ' of Chill\MainBundle\Entity\User');
|
||||
}
|
||||
$this->personACLAwareRepository = $personACLAwareRepository;
|
||||
$this->templating = $templating;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* (non-PHPdoc)
|
||||
* @see \Chill\MainBundle\Search\SearchInterface::getOrder()
|
||||
@@ -94,7 +36,7 @@ class SimilarityPersonSearch extends AbstractSearch
|
||||
{
|
||||
return 200;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* (non-PHPdoc)
|
||||
* @see \Chill\MainBundle\Search\SearchInterface::isActiveByDefault()
|
||||
@@ -103,30 +45,27 @@ class SimilarityPersonSearch extends AbstractSearch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function supports($domain, $format)
|
||||
{
|
||||
return 'person' === $domain;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param array $terms
|
||||
* @param int $start
|
||||
* @param int $limit
|
||||
* @param array $options
|
||||
* @param string $format
|
||||
* @return array
|
||||
*/
|
||||
public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html')
|
||||
{
|
||||
$total = $this->count($terms);
|
||||
$paginator = $this->paginatorFactory->create($total);
|
||||
|
||||
if ($format === 'html')
|
||||
{
|
||||
if ($total !== 0)
|
||||
{
|
||||
return $this->container->get('templating')->render('ChillPersonBundle:Person:list.html.twig',
|
||||
|
||||
if ($format === 'html') {
|
||||
if ($total !== 0) {
|
||||
return $this->templating->render('ChillPersonBundle:Person:list.html.twig',
|
||||
array(
|
||||
'persons' => $this->search($terms, $start, $limit, $options),
|
||||
'pattern' => $this->recomposePattern($terms, array('nationality',
|
||||
@@ -143,9 +82,8 @@ class SimilarityPersonSearch extends AbstractSearch
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
|
||||
} elseif ($format === 'json')
|
||||
{
|
||||
|
||||
} elseif ($format === 'json') {
|
||||
return [
|
||||
'results' => $this->search($terms, $start, $limit, \array_merge($options, [ 'simplify' => true ])),
|
||||
'pagination' => [
|
||||
@@ -154,8 +92,7 @@ class SimilarityPersonSearch extends AbstractSearch
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param string $pattern
|
||||
@@ -166,101 +103,12 @@ class SimilarityPersonSearch extends AbstractSearch
|
||||
*/
|
||||
protected function search(array $terms, $start, $limit, array $options = array())
|
||||
{
|
||||
$qb = $this->createQuery($terms, 'search');
|
||||
|
||||
|
||||
if ($options['simplify'] ?? false) {
|
||||
$qb->select(
|
||||
'sp.id',
|
||||
$qb->expr()->concat(
|
||||
'sp.firstName',
|
||||
$qb->expr()->literal(' '),
|
||||
'sp.lastName'
|
||||
).'AS text'
|
||||
);
|
||||
} else {
|
||||
$qb->select('sp');
|
||||
}
|
||||
|
||||
$qb
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($start);
|
||||
|
||||
//order by firstname, lastname
|
||||
|
||||
$qb
|
||||
->orderBy('sp.firstName')
|
||||
->addOrderBy('sp.lastName');
|
||||
|
||||
if ($options['simplify'] ?? false) {
|
||||
return $qb->getQuery()->getResult(Query::HYDRATE_ARRAY);
|
||||
} else {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
return $this->personACLAwareRepository
|
||||
->findBySimilaritySearch($terms['_default']);
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected function count(array $terms)
|
||||
{
|
||||
$qb = $this->createQuery($terms);
|
||||
|
||||
|
||||
$qb->select('COUNT(sp.id)');
|
||||
|
||||
return $qb->getQuery()->getSingleScalarResult();
|
||||
return $this->personACLAwareRepository->countBySimilaritySearch($terms['_default']);
|
||||
}
|
||||
|
||||
|
||||
private $_cacheQuery = array();
|
||||
|
||||
/**
|
||||
*
|
||||
* @param array $terms
|
||||
* @return \Doctrine\ORM\QueryBuilder
|
||||
*/
|
||||
protected function createQuery(array $terms)
|
||||
{
|
||||
//get from cache
|
||||
$cacheKey = md5(serialize($terms));
|
||||
if (array_key_exists($cacheKey, $this->_cacheQuery)) {
|
||||
return clone $this->_cacheQuery[$cacheKey];
|
||||
}
|
||||
|
||||
$qb = $this->em->createQueryBuilder();
|
||||
|
||||
$qb ->select('sp')
|
||||
->from('ChillPersonBundle:Person', 'sp');
|
||||
|
||||
if ($terms['_default'] !== '') {
|
||||
$grams = explode(' ', $terms['_default']);
|
||||
|
||||
foreach($grams as $key => $gram) {
|
||||
$qb->andWhere('SIMILARITY(sp.fullnameCanonical, UNACCENT(LOWER(:default_'.$key.')) ) >= 0.15')
|
||||
->setParameter('default_'.$key, '%'.$gram.'%');
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()
|
||||
->notIn(
|
||||
'sp.id',
|
||||
$this->personSearch
|
||||
->createQuery($terms)
|
||||
->addSelect('p.id')
|
||||
->getDQL()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
//restraint center for security
|
||||
$reachableCenters = $this->helper->getReachableCenters($this->user,
|
||||
new Role('CHILL_PERSON_SEE'));
|
||||
$qb->andWhere($qb->expr()
|
||||
->in('sp.center', ':centers'))
|
||||
->setParameter('centers', $reachableCenters)
|
||||
;
|
||||
|
||||
$this->_cacheQuery[$cacheKey] = $qb;
|
||||
|
||||
return clone $qb;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user