vendor/isotope/isotope-core/system/modules/isotope/library/Isotope/Module/ProductList.php line 274

Open in your IDE?
  1. <?php
  2. /*
  3.  * Isotope eCommerce for Contao Open Source CMS
  4.  *
  5.  * Copyright (C) 2009 - 2019 terminal42 gmbh & Isotope eCommerce Workgroup
  6.  *
  7.  * @link       https://isotopeecommerce.org
  8.  * @license    https://opensource.org/licenses/lgpl-3.0.html
  9.  */
  10. namespace Isotope\Module;
  11. use Contao\Controller;
  12. use Contao\CoreBundle\Exception\PageNotFoundException;
  13. use Contao\CoreBundle\Exception\RedirectResponseException;
  14. use Contao\CoreBundle\Exception\ResponseException;
  15. use Contao\Database;
  16. use Contao\Date;
  17. use Contao\Environment;
  18. use Contao\Pagination;
  19. use Contao\System;
  20. use Haste\Generator\RowClass;
  21. use Haste\Input\Input;
  22. use Isotope\Collection\ProductPrice as ProductPriceCollection;
  23. use Isotope\Interfaces\IsotopeProduct;
  24. use Isotope\Isotope;
  25. use Isotope\Model\Attribute;
  26. use Isotope\Model\Product;
  27. use Isotope\Model\ProductCache;
  28. use Isotope\Model\ProductPrice;
  29. use Isotope\RequestCache\FilterQueryBuilder;
  30. use Isotope\RequestCache\Sort;
  31. use Isotope\Template;
  32. use Symfony\Component\HttpFoundation\Response;
  33. /**
  34.  * @property string $iso_list_layout
  35.  * @property int    $iso_cols
  36.  * @property bool   $iso_use_quantity
  37.  * @property int    $iso_gallery
  38.  * @property array  $iso_filterModules
  39.  * @property array  $iso_productcache
  40.  * @property string $iso_listingSortField
  41.  * @property string $iso_listingSortDirection
  42.  * @property bool   $iso_jump_first
  43.  */
  44. class ProductList extends Module
  45. {
  46.     /**
  47.      * Template
  48.      * @var string
  49.      */
  50.     protected $strTemplate 'mod_iso_productlist';
  51.     /**
  52.      * Cache products. Can be disable in a child class, e.g. a "random products list"
  53.      * @var boolean
  54.      *
  55.      * @deprecated Deprecated since version 2.3, to be removed in 3.0.
  56.      *             Implement getCacheKey() to always cache result.
  57.      */
  58.     protected $blnCacheProducts true;
  59.     /**
  60.      * @inheritDoc
  61.      */
  62.     protected function getSerializedProperties()
  63.     {
  64.         $props parent::getSerializedProperties();
  65.         $props[] = 'iso_filterModules';
  66.         $props[] = 'iso_productcache';
  67.         return $props;
  68.     }
  69.     /**
  70.      * Display a wildcard in the back end
  71.      * @return string
  72.      */
  73.     public function generate()
  74.     {
  75.         if ('BE' === TL_MODE) {
  76.             return $this->generateWildcard();
  77.         }
  78.         // Hide product list in reader mode if the respective setting is enabled
  79.         if ($this->iso_hide_list && Input::getAutoItem('product'falsetrue) != '') {
  80.             return '';
  81.         }
  82.         // Disable the cache in frontend preview or debug mode
  83.         if (BE_USER_LOGGED_IN === true || System::getContainer()->getParameter('kernel.debug')) {
  84.             $this->blnCacheProducts false;
  85.         }
  86.         // Apply limit from filter module
  87.         $this->perPage Isotope::getRequestCache()
  88.             ->getFirstLimitForModules($this->iso_filterModules$this->perPage)
  89.             ->asInt()
  90.         ;
  91.         return parent::generate();
  92.     }
  93.     /**
  94.      * Compile product list.
  95.      *
  96.      * This function is specially designed so you can keep it in your child classes and only override findProducts().
  97.      * You will automatically gain product caching (see class property), grid classes, pagination and more.
  98.      */
  99.     protected function compile()
  100.     {
  101.         // return message if no filter is set
  102.         if ($this->iso_emptyFilter && !Input::get('isorc') && !Input::get('keywords')) {
  103.             $this->Template->message  Controller::replaceInsertTags($this->iso_noFilter);
  104.             $this->Template->type     'noFilter';
  105.             $this->Template->products = array();
  106.             return;
  107.         }
  108.         global $objPage;
  109.         $cacheKey      $this->getCacheKey();
  110.         $arrProducts   null;
  111.         $arrCacheIds   null;
  112.         // Try to load the products from cache
  113.         if ($this->blnCacheProducts && ($objCache ProductCache::findByUniqid($cacheKey)) !== null) {
  114.             $arrCacheIds $objCache->getProductIds();
  115.             // Use the cache if keywords match. Otherwise we will use the product IDs as a "limit" for findProducts()
  116.             if ($objCache->keywords == Input::get('keywords')) {
  117.                 $arrCacheIds $this->generatePagination($arrCacheIds);
  118.                 $objProducts Product::findAvailableByIds($arrCacheIds, array(
  119.                     'order' => Database::getInstance()->findInSet(Product::getTable().'.id'$arrCacheIds)
  120.                 ));
  121.                 $arrProducts = (null === $objProducts) ? array() : $objProducts->getModels();
  122.                 // Cache is wrong, drop everything and run findProducts()
  123.                 if (\count($arrProducts) != \count($arrCacheIds)) {
  124.                     $arrCacheIds null;
  125.                     $arrProducts null;
  126.                 }
  127.             }
  128.         }
  129.         if (!\is_array($arrProducts)) {
  130.             // Display "loading products" message and add cache flag
  131.             if ($this->blnCacheProducts) {
  132.                 $blnCacheMessage = (bool) ($this->iso_productcache[$cacheKey] ?? false);
  133.                 if ($blnCacheMessage && !Input::get('buildCache')) {
  134.                     // Do not index or cache the page
  135.                     $objPage->noSearch 1;
  136.                     $objPage->cache    0;
  137.                     $this->Template          = new Template('mod_iso_productlist_caching');
  138.                     $this->Template->message $GLOBALS['TL_LANG']['MSC']['productcacheLoading'];
  139.                     return;
  140.                 }
  141.                 // Start measuring how long it takes to load the products
  142.                 $start microtime(true);
  143.                 // Load products
  144.                 $arrProducts $this->findProducts($arrCacheIds);
  145.                 // Decide if we should show the "caching products" message the next time
  146.                 $end microtime(true) - $start;
  147.                 $this->blnCacheProducts $end 1;
  148.                 $arrCacheMessage $this->iso_productcache;
  149.                 if ($blnCacheMessage !== $this->blnCacheProducts) {
  150.                     $arrCacheMessage[$cacheKey] = $this->blnCacheProducts;
  151.                     Database::getInstance()
  152.                         ->prepare('UPDATE tl_module SET iso_productcache=? WHERE id=?')
  153.                         ->execute(serialize($arrCacheMessage), $this->id)
  154.                     ;
  155.                 }
  156.                 // Do not write cache if table is locked. That's the case if another process is already writing cache
  157.                 if (ProductCache::isWritable()) {
  158.                     Database::getInstance()
  159.                         ->lockTables(array(ProductCache::getTable() => 'WRITE''tl_iso_product' => 'READ'))
  160.                     ;
  161.                     $arrIds = array();
  162.                     foreach ($arrProducts as $objProduct) {
  163.                         $arrIds[] = $objProduct->id;
  164.                     }
  165.                     // Delete existing cache if necessary
  166.                     ProductCache::deleteByUniqidOrExpired($cacheKey);
  167.                     $objCache          ProductCache::createForUniqid($cacheKey);
  168.                     $objCache->expires $this->getProductCacheExpiration();
  169.                     $objCache->setProductIds($arrIds);
  170.                     $objCache->save();
  171.                     Database::getInstance()->unlockTables();
  172.                 }
  173.             } else {
  174.                 $arrProducts $this->findProducts();
  175.             }
  176.             if (!empty($arrProducts)) {
  177.                 $arrProducts $this->generatePagination($arrProducts);
  178.             }
  179.         }
  180.         // No products found
  181.         if (!\is_array($arrProducts) || empty($arrProducts)) {
  182.             $this->compileEmptyMessage();
  183.             return;
  184.         }
  185.         $arrBuffer         = array();
  186.         $arrDefaultOptions $this->getDefaultProductOptions();
  187.         // Prepare optimized product categories
  188.         $preloadData $this->batchPreloadProducts();
  189.         /** @var \Isotope\Model\Product\Standard $objProduct */
  190.         foreach ($arrProducts as $objProduct) {
  191.             if ($objProduct instanceof Product\Standard) {
  192.                 if (isset($preloadData['categories'][$objProduct->id])) {
  193.                     $objProduct->setCategories($preloadData['categories'][$objProduct->id], true);
  194.                 }
  195.                 if (!$objProduct->hasAdvancedPrices()) {
  196.                     if ($objProduct->hasVariantPrices() && !$objProduct->isVariant()) {
  197.                         $ids $objProduct->getVariantIds();
  198.                     } else {
  199.                         $ids = [$objProduct->hasVariantPrices() ? $objProduct->getId() : $objProduct->getProductId()];
  200.                     }
  201.                     $prices array_intersect_key($preloadData['prices'], array_flip($ids));
  202.                     if (!empty($prices)) {
  203.                         $objProduct->setPrice(new ProductPriceCollection($pricesProductPrice::getTable()));
  204.                     }
  205.                 }
  206.             }
  207.             $arrConfig $this->getProductConfig($objProduct);
  208.             if (Environment::get('isAjaxRequest')
  209.                 && Input::post('AJAX_MODULE') == $this->id
  210.                 && Input::post('AJAX_PRODUCT') == $objProduct->getProductId()
  211.                 && !$this->iso_disable_options
  212.             ) {
  213.                 $content $objProduct->generate($arrConfig);
  214.                 $content Controller::replaceInsertTags($contentfalse);
  215.                 throw new ResponseException(new Response($content));
  216.             }
  217.             $objProduct->mergeRow($arrDefaultOptions);
  218.             // Must be done after setting options to generate the variant config into the URL
  219.             if ($this->iso_jump_first && Input::getAutoItem('product'falsetrue) == '') {
  220.                 throw new RedirectResponseException($objProduct->generateUrl($arrConfig['jumpTo'], true));
  221.             }
  222.             $arrBuffer[] = array(
  223.                 'cssID'     => $objProduct->getCssId(),
  224.                 'class'     => $objProduct->getCssClass(),
  225.                 'html'      => $objProduct->generate($arrConfig),
  226.                 'product'   => $objProduct,
  227.             );
  228.         }
  229.         // HOOK: to add any product field or attribute to mod_iso_productlist template
  230.         if (isset($GLOBALS['ISO_HOOKS']['generateProductList'])
  231.             && \is_array($GLOBALS['ISO_HOOKS']['generateProductList'])
  232.         ) {
  233.             foreach ($GLOBALS['ISO_HOOKS']['generateProductList'] as $callback) {
  234.                 $arrBuffer System::importStatic($callback[0])->{$callback[1]}($arrBuffer$arrProducts$this->Template$this);
  235.             }
  236.         }
  237.         RowClass::withKey('class')
  238.             ->addCount('product_')
  239.             ->addEvenOdd('product_')
  240.             ->addFirstLast('product_')
  241.             ->addGridRows($this->iso_cols)
  242.             ->addGridCols($this->iso_cols)
  243.             ->applyTo($arrBuffer)
  244.         ;
  245.         $this->Template->products $arrBuffer;
  246.     }
  247.     /**
  248.      * Find all products we need to list.
  249.      *
  250.      * @param array|null $arrCacheIds
  251.      *
  252.      * @return array
  253.      */
  254.     protected function findProducts($arrCacheIds null)
  255.     {
  256.         $arrColumns = array();
  257.         $arrFilters Isotope::getRequestCache()->getFiltersForModules($this->iso_filterModules);
  258.         $arrCategories $this->findCategories($arrFilters);
  259.         $queryBuilder = new FilterQueryBuilder($arrFilters);
  260.         $arrColumns[] = Product::getTable().'.pid=0';
  261.         if (=== \count($arrCategories)) {
  262.             $arrColumns[] = "c.page_id=".reset($arrCategories);
  263.         } else {
  264.             $arrColumns[] = "c.page_id IN (".implode(','$arrCategories).")";
  265.         }
  266.         if (!empty($arrCacheIds) && \is_array($arrCacheIds)) {
  267.             $arrColumns[] = Product::getTable() . ".id IN (" implode(','$arrCacheIds) . ")";
  268.         }
  269.         // Apply new/old product filter
  270.         if ('show_new' === $this->iso_newFilter) {
  271.             $arrColumns[] = Product::getTable() . ".dateAdded>=" Isotope::getConfig()->getNewProductLimit();
  272.         } elseif ('show_old' === $this->iso_newFilter) {
  273.             $arrColumns[] = Product::getTable() . ".dateAdded<" Isotope::getConfig()->getNewProductLimit();
  274.         }
  275.         if ($this->iso_list_where != '') {
  276.             $arrColumns[] = $this->iso_list_where;
  277.         }
  278.         if ($queryBuilder->hasSqlCondition()) {
  279.             $arrColumns[] = $queryBuilder->getSqlWhere();
  280.         }
  281.         $arrSorting Isotope::getRequestCache()->getSortingsForModules($this->iso_filterModules);
  282.         if (empty($arrSorting) && $this->iso_listingSortField != '') {
  283.             $direction = ('DESC' === $this->iso_listingSortDirection Sort::descending() : Sort::ascending());
  284.             $arrSorting[$this->iso_listingSortField] = $direction;
  285.         }
  286.         $objProducts Product::findAvailableBy(
  287.             $arrColumns,
  288.             $queryBuilder->getSqlValues(),
  289.             array(
  290.                  'order'   => === \count($arrCategories) ? 'c.sorting' null,
  291.                  'filters' => $queryBuilder->getFilters(),
  292.                  'sorting' => $arrSorting,
  293.             )
  294.         );
  295.         return (null === $objProducts) ? array() : $objProducts->getModels();
  296.     }
  297.     /**
  298.      * Compile template to show a message if there are no products
  299.      *
  300.      * @param bool $disableSearchIndex
  301.      */
  302.     protected function compileEmptyMessage($disableSearchIndex true)
  303.     {
  304.         global $objPage;
  305.         // Do not index or cache the page
  306.         if ($disableSearchIndex) {
  307.             $objPage->noSearch 1;
  308.             $objPage->cache    0;
  309.         }
  310.         $message $this->iso_emptyMessage $this->iso_noProducts $GLOBALS['TL_LANG']['MSC']['noProducts'];
  311.         $this->Template->empty    true;
  312.         $this->Template->type     'empty';
  313.         $this->Template->message  $message;
  314.         $this->Template->products = array();
  315.     }
  316.     /**
  317.      * Generate the pagination
  318.      *
  319.      * @param array $arrItems
  320.      *
  321.      * @return array
  322.      */
  323.     protected function generatePagination($arrItems)
  324.     {
  325.         $offset 0;
  326.         $limit  null;
  327.         // Set the limit
  328.         if ($this->numberOfItems 0) {
  329.             $limit $this->numberOfItems;
  330.         }
  331.         $pagination '';
  332.         $page       1;
  333.         $total      \count($arrItems);
  334.         // Split the results
  335.         if ($this->perPage && (!isset($limit) || $limit $this->perPage)) {
  336.             // Adjust the overall limit
  337.             if (isset($limit)) {
  338.                 $total min($limit$total);
  339.             }
  340.             // Get the current page
  341.             $id   'page_iso' $this->id;
  342.             $page Input::get($id) ?: 1;
  343.             // Do not index or cache the page if the page number is outside the range
  344.             if ($page || $page max(ceil($total $this->perPage), 1)) {
  345.                 throw new PageNotFoundException();
  346.             }
  347.             // Set limit and offset
  348.             $limit $this->perPage;
  349.             $offset += (max($page1) - 1) * $this->perPage;
  350.             // Overall limit
  351.             if ($offset $limit $total) {
  352.                 $limit $total $offset;
  353.             }
  354.             // Add the pagination menu
  355.             $objPagination = new Pagination($total$this->perPage$GLOBALS['TL_CONFIG']['maxPaginationLinks'], $id);
  356.             $pagination $objPagination->generate("\n  ");
  357.         }
  358.         $this->Template->pagination $pagination;
  359.         $this->Template->total      \count($arrItems);
  360.         $this->Template->page       $page;
  361.         $this->Template->offset     $offset;
  362.         $this->Template->limit      $limit;
  363.         if (isset($limit)) {
  364.             $arrItems \array_slice($arrItems$offset$limit);
  365.         }
  366.         return $arrItems;
  367.     }
  368.     /**
  369.      * Get filter & sorting configuration
  370.      *
  371.      * @param boolean
  372.      *
  373.      * @return array
  374.      *
  375.      * @deprecated Deprecated since Isotope 2.3, to be removed in 3.0.
  376.      *             Use Isotope\RequestCache\FilterQueryBuilder instead.
  377.      */
  378.     protected function getFiltersAndSorting($blnNativeSQL true)
  379.     {
  380.         $arrFilters Isotope::getRequestCache()->getFiltersForModules($this->iso_filterModules);
  381.         $arrSorting Isotope::getRequestCache()->getSortingsForModules($this->iso_filterModules);
  382.         if (empty($arrSorting) && $this->iso_listingSortField != '') {
  383.             $direction = ('DESC' === $this->iso_listingSortDirection Sort::descending() : Sort::ascending());
  384.             $arrSorting[$this->iso_listingSortField] = $direction;
  385.         }
  386.         if (!$blnNativeSQL) {
  387.             return array($arrFilters$arrSorting);
  388.         }
  389.         $queryBuilder = new FilterQueryBuilder($arrFilters);
  390.         return array(
  391.             $queryBuilder->getFilters(),
  392.             $arrSorting,
  393.             $queryBuilder->getSqlWhere(),
  394.             $queryBuilder->getSqlValues()
  395.         );
  396.     }
  397.     /**
  398.      * Get a list of default options based on filter attributes
  399.      * @return array
  400.      */
  401.     protected function getDefaultProductOptions()
  402.     {
  403.         $arrFields  array_merge(Attribute::getVariantOptionFields(), Attribute::getCustomerDefinedFields());
  404.         if (empty($arrFields)) {
  405.             return array();
  406.         }
  407.         $arrOptions = array();
  408.         $arrFilters Isotope::getRequestCache()->getFiltersForModules($this->iso_filterModules);
  409.         foreach ($arrFilters as $arrConfig) {
  410.             if (\in_array($arrConfig['attribute'], $arrFields)
  411.                 && ('=' === $arrConfig['operator'] || '==' === $arrConfig['operator'] || 'eq' === $arrConfig['operator'])
  412.             ) {
  413.                 $arrOptions[$arrConfig['attribute']] = $arrConfig['value'];
  414.             }
  415.         }
  416.         return $arrOptions;
  417.     }
  418.     /**
  419.      * Generates a unique cache key for the product cache.
  420.      * Child classes should likely overwrite this, see RelatedProducts class for an example.
  421.      *
  422.      * @return string A 32 char cache key (e.g. MD5)
  423.      */
  424.     protected function getCacheKey()
  425.     {
  426.         $categories $this->findCategories();
  427.         // Sort categories so cache key is always the same
  428.         sort($categories);
  429.         return md5(
  430.             'productlist=' $this->id ':'
  431.             'where=' $this->iso_list_where ':'
  432.             'isorc=' . (int) Input::get('isorc') . ':'
  433.             implode(','$categories)
  434.         );
  435.     }
  436.     /**
  437.      * Returns the timestamp when the product cache expires
  438.      *
  439.      * @return int
  440.      */
  441.     protected function getProductCacheExpiration()
  442.     {
  443.         $time Date::floorToMinute();
  444.         // Find timestamp when the next product becomes available
  445.         $expires = (int) Database::getInstance()
  446.             ->execute("SELECT MIN(start) AS expires FROM tl_iso_product WHERE start>'$time'")
  447.             ->expires
  448.         ;
  449.         // Find
  450.         if ('show_new' === $this->iso_newFilter || 'show_old' === $this->iso_newFilter) {
  451.             $added Database::getInstance()
  452.                 ->execute("
  453.                     SELECT MIN(dateAdded) AS expires
  454.                     FROM tl_iso_product
  455.                     WHERE dateAdded>" Isotope::getConfig()->getNewProductLimit() . "
  456.                 ")->expires
  457.             ;
  458.             if ($added $expires) {
  459.                 $expires $added;
  460.             }
  461.         }
  462.         return $expires;
  463.     }
  464.     protected function getProductConfig(IsotopeProduct $product)
  465.     {
  466.         $type $product->getType();
  467.         return array(
  468.             'module'         => $this,
  469.             'template'       => $this->iso_list_layout ?: $type->list_template,
  470.             'gallery'        => $this->iso_gallery ?: $type->list_gallery,
  471.             'buttons'        => $this->iso_buttons,
  472.             'useQuantity'    => $this->iso_use_quantity,
  473.             'disableOptions' => $this->iso_disable_options,
  474.             'jumpTo'         => $this->findJumpToPage($product),
  475.         );
  476.     }
  477.     private function batchPreloadProducts()
  478.     {
  479.         $query "SELECT c.pid, GROUP_CONCAT(c.page_id) AS page_ids FROM tl_iso_product_category c JOIN tl_page p ON c.page_id=p.id WHERE p.type!='error_403' AND p.type!='error_404'";
  480.         if (!BE_USER_LOGGED_IN) {
  481.             $time Date::floorToMinute();
  482.             $query .= " AND p.published='1' AND (p.start='' OR p.start<'$time') AND (p.stop='' OR p.stop>'" . ($time 60) . "')";
  483.         }
  484.         $query .= " GROUP BY c.pid";
  485.         $data = ['categories' => [], 'prices' => []];
  486.         $result Database::getInstance()->execute($query);
  487.         while ($row $result->fetchAssoc()) {
  488.             $data['categories'][$row['pid']] = explode(','$row['page_ids']);
  489.         }
  490.         $t ProductPrice::getTable();
  491.         $arrOptions = [
  492.             'column' => [
  493.                 "$t.config_id=0",
  494.                 "$t.member_group=0",
  495.                 "$t.start=''",
  496.                 "$t.stop=''",
  497.             ],
  498.         ];
  499.         /** @var ProductPriceCollection $prices */
  500.         $prices ProductPrice::findAll($arrOptions);
  501.         if (null !== $prices) {
  502.             foreach ($prices as $price) {
  503.                 if (!isset($data['prices'][$price->pid])) {
  504.                     $data['prices'][$price->pid] = $price;
  505.                 }
  506.             }
  507.         }
  508.         return $data;
  509.     }
  510. }