Prestashop Module - Enhanced Product List in Admin

Published May 5, 2021, 8:05 p.m.

Today we are returning to Prestashop modules programming. What game shall we play today?

There are a number of modules which allows us to customize catalog view - in the front end. But what if we want to customize the back office? Namely, we are missing some columns in Product List view. It would be nice to see all our EAN13 codes in the list, as well as such values like Cost Price and Weight - these values are often set being set incorrectly by users and usually it is difficult to trace. Only when you trying to post an order which surprisingly weights 0 grams and costs 0 pence to ship you are starting to realize that some values are missing in the product description.

We have all the means to add columns. First of all, there is a hook actionAdminProductsListingFieldsModifier - and it does what the name suggests - it allows to modify fields in the select which is used to build the product list.

actionAdminProductsListingFieldsModifier
Located in: /src/Adapter/Product/AdminProductDataProvider.php
Parameters:

<?php
array(
  '_ps_version' => (string) PrestaShop version,
  'sql_select' => &(array),
  'sql_table' => &(array),
  'sql_where' => &(array),
  'sql_order' => &(array),
  'sql_limit' => &(string),
);

(It always helps to keep a copy of full Prestashop sources at hand. Looking at source file often helps to get the idea. In my case first came the discovery that this hook is called twice, most probably by mistake. Ok, mistake or not, now we are prepared to deal with this fact.)

Let's create a module skeleton, as we already did in the previous article. Go here:

https://validator.prestashop.com/generator

I couldn't find the needed hook in the dropdown, so we'll add it later.

After downloading and unzipping our brand new module, let's add code. Let's add the needed hook:

public function install()
{
        Configuration::updateValue('ADMINEXTRAINFO_LIVE_MODE', false);

        return parent::install() &&
            $this->registerHook('backOfficeHeader') &&
            $this->registerHook('actionAdminProductsListingFieldsModifier');  // added line
}

in the install method, and the hook implementation at the bottom of the file:

public function hookActionAdminProductsListingFieldsModifier($param)
{
        /* Place your code here. */
        print('***==hookActionAdminProductsListingFieldsModifier==***');
        var_dump($param);
}

just to make sure it works.

And it does. This is what we immediately see in the page source:

***==hookActionAdminProductsListingFieldsModifier==***array(10) {
  ["_ps_version"]=>
  string(7) "1.7.7.3"
  ["sql_select"]=>
  &array(16) {
    ["id_product"]=>
    array(3) {
      ["table"]=>
      string(1) "p"
      ["field"]=>
      string(10) "id_product"
      ["filtering"]=>
      string(4) " %s "
    }
    ["reference"]=>
    array(3) {
      ["table"]=>
      string(1) "p"
      ["field"]=>
      string(9) "reference"
      ["filtering"]=>
      string(13) "LIKE '%%%s%%'"
    }
    ["price"]=>
    array(3) {
      ["table"]=>
      string(2) "sa"
      ["field"]=>
      string(5) "price"
      ["filtering"]=>
      string(4) " %s "
    }
        ...

Scrolling down, we see that this is repeated twice - exactly what we expect from examining the code in AdminProductDataProvider.php. Ok, but the content is self-explaining. We only need to add new fields to the param in the same format. A very simple task, knowing that the fields we need are located in the same tables... And do not forget to prevent double execution.

This is what we will put in the hook:

public function hookActionAdminProductsListingFieldsModifier($param)
{
        /* Place your code here. */
        if (!$param['sql_select']['ean13']) {
                $param['sql_select']['ean13'] = array();
                $param['sql_select']['ean13']["table"] = "p";
                $param['sql_select']['ean13']["field"] = "ean13";
                $param['sql_select']['ean13']["filtering"] = "LIKE '%%%s%%'";
        }

        if (!$param['sql_select']['weight']) {
                $param['sql_select']['weight'] = array();
                $param['sql_select']['weight']["table"] = "p";
                $param['sql_select']['weight']["field"] = "weight";
                $param['sql_select']['weight']["filtering"] = ' %s ';
        }

        if (!$param['sql_select']['wholesale_price']) {
                $param['sql_select']['wholesale_price'] = array();
                $param['sql_select']['wholesale_price']["table"] = "sa";
                $param['sql_select']['wholesale_price']["field"] = "wholesale_price";
                $param['sql_select']['wholesale_price']["filtering"] = ' %s ';
        }
}

But obviously this is not enough. We need to display these new fields. Luckily for us, if we search Prestashop Module Development documentation, there is a page which refers exactly to our case! We need to add a subtree to module's views subfolder like this:

├───sql
├───translations
├───upgrade
└───views
    ├───css
    ├───img
    ├───js
    ├───PrestaShop
    │   └───Admin
    │       └───Product
    │           └───CatalogPage
    │               └───Lists
    └───templates
        └───admin

And the content of this subdir will be taken from this subfolder of Prestashop sources:

prestashop_1.7.7.3/src/PrestaShopBundle/Resources/views/Admin 

Initially we will override only one file,

/src/PrestaShopBundle/Resources/views/Admin/Product/CatalogPage/Lists/list.html.twig 

I never heard of .twig templates before, but what I see here looks exactly like Django templates or Jinja templates. Looks like all templating languages uses the same syntax... Good for us! (But need to notice that this syntax is slightly different from Smarty used in the front end and older parts of the back end.)

We see that products are displayed inside an HTML table and list.html.twig represents a tbody row. For simplicity, lets add EAN13 field in the same column as the product id, to keep thead unchanged:

 <td>
        <label class="form-check-label" for="bulk_action_selected_products-{{ product.id_product }}">
                {{ product.id_product }}
        </label>
        <br/>
        <a href="{{ product.url|default('') }}#tab-step6">{{ product.ean13 }}</a>
    </td>

I copied the idea from some other field. #tab-step6 in the link refers to the tab "options" in product description, where EAN13 is being set. The same way, let's put "weight" field under product category and wholesale price under price:

<td>
        {{ product.name_category|default('') }}
        <br>
        <a href="{{ product.url|default('') }}#tab-step4">{{ product.weight|round(2) }} kg</a>
</td>
<td class="text-center">
        <a href="{{ product.url|default('') }}#tab-step2">{{ product.price|default('N/A'|trans({}, 'Admin.Global')) }}</a>
        <br>
        <a href="{{ product.url|default('') }}#tab-step2">{{ product.wholesale_price|round(2) }}</a>
</td>

We need to use round(2) filter to give our numbers a good look, otherwise all 8 or so decimal digits will be displayed. Why it is not needed for product.price? Because it is done inside a php code, together with prepending (or appending) a currency sign. We would want to do the same with the wholesale price, but for simplicity let's live it as is for now. Let's also skip internationalization for 'kg' word.

To change the table header, we need to override, well, products_table.html.twig template. And what I have found, trying to implement filtering and sorting by our new fields - it doesn't work. Although we have defined, and hopefully correctly, all needed attributes for the new fields to be used in filtering and sorting, we will also need to modify some javascript somewhere. Filtering and sorting are performed by ajax call, and additional programming is required. We need to teach some javascript to deal with the new fields. Let's leave it for the next exercise. (And for my client's purpose this unfinished solution is already a big help.)

So below is my version of products_table.html.twig:

{% block product_catalog_form_table_header %}
    <tr class="column-headers">
        <th scope="col" style="width: 2rem"></th>
        <th scope="col" style="width: 6%">
            {{ ps.sortable_column_header("ID"|trans({}, 'Admin.Global'), 'id_product', orderBy, sortOrder) }}
        </th>
        <th scope="col">
            {{ "Image"|trans({}, 'Admin.Global') }}
        </th>
        <th scope="col">
            {{ ps.sortable_column_header("Name"|trans({}, 'Admin.Global'), 'name', orderBy, sortOrder) }}
        </th>
        <th scope="col" style="width: 9%">
            {{ ps.sortable_column_header("Reference"|trans({}, 'Admin.Global'), 'reference', orderBy, sortOrder) }}
        </th>
        <th scope="col">
            {{ ps.sortable_column_header("Category"|trans({}, 'Admin.Catalog.Feature'), 'name_category', orderBy, sortOrder) }}
        </th>
        <th scope="col" class="text-center" style="width: 6%">
            {{ ps.sortable_column_header("Price ex VAT"|trans({}, 'Admin.Catalog.Feature'), 'price', orderBy, sortOrder) }}
        </th>
        <th scope="col" class="text-center" style="width: 6%">
            {{ "Price VAT"|trans({}, 'Admin.Catalog.Feature') }}
        </th>

        {% if 'PS_STOCK_MANAGEMENT'|configuration %}
        <th scope="col" class="text-center" style="width: 6%">
            {{ ps.sortable_column_header("Quant"|trans({}, 'Admin.Catalog.Feature'), 'sav_quantity', orderBy, sortOrder) }}
        </th>
        {% else %}
            <th></th>
        {% endif %}

        <th scope="col" class="text-center">
            {{ ps.sortable_column_header("Status"|trans({}, 'Admin.Global'), 'active', orderBy, sortOrder) }}
        </th>
        {% if has_category_filter == true %}
            <th scope="col">
                {{ ps.sortable_column_header("Pos"|trans({}, 'Admin.Global'), 'position', orderBy, sortOrder) }}
            </th>
        {% endif %}
        <th scope="col" class="text-right" style="width: 3rem; padding-right: 2rem">
                {{ "Actions"|trans({}, 'Admin.Global') }}
        </th>
    </tr>
    <tr class="column-headers">
        <th></th>
        <th>
            {{ "EAN13"|trans({}, 'Admin.Catalog.Feature') }}
        </th>
        <th></th>
        <th></th>
        <th></th>
        <th>
            {{ "Weight"|trans({}, 'Admin.Global') }}
        </th>
        <th>
            {{ "Cost Price"|trans({}, 'Admin.Catalog.Feature') }}
        </th>
        <th></th>
        <th></th>
        <th></th>
        {% if has_category_filter == true %}
            <th></th>
        {% endif %}
        <th></th>
    </tr>
{% endblock %}

Here is a Github repository:

https://github.com/vallka/prestamodule_adminextrainfo

To be continued...

This is the 3rd part of exploration of pandas package.

Part 2: https://www.vallka.com/blog/pandas-in-the-pandaemic-covid-19-in-scotland-statistics-part-2/

Part 1: https://www.vallka.com/blog/leaning-pandas-data-manipulation-and-visualization-covid-19-in-scotland-statistics/

All data are taken from the official websate: weekly-deaths-by-location-age-sex.xlsx

Taken from official the site:

https://www.nrscotland.gov.uk/statistics-and-data/statistics/statistics-by-theme/vital-events/general-publications/weekly-and-monthly-data-on-births-and-deaths/deaths-involving-coronavirus-covid-19-in-scotland/related-statistics

Let's have another look at the graph from the previous part: https://colab.research.google.com/drive/1a5FyWN5psehoqnAUEev0ODE2KNMiFTTE?usp=sharing

New data arrived. The line for the last week is going up a little, but still well below the average.

Read more...

This is the 2nd part of exploration of pandas package. Part 1 can be found here: https://www.vallka.com/blog/leaning-pandas-data-manipulation-and-visualization-covid-19-in-scotland-statistics/

All data are taken from the official websate:

weekly-deaths-by-location-age-sex.xlsx

Taken from official the site:

https://www.nrscotland.gov.uk/statistics-and-data/statistics/statistics-by-theme/vital-events/general-publications/weekly-and-monthly-data-on-births-and-deaths/deaths-involving-coronavirus-covid-19-in-scotland/related-statistics

Let's quickly repeat data load process from the Part 1:

import numpy as np
import pandas as pd

data_2021 = pd.read_excel ('https://www.nrscotland.gov.uk/files//statistics/covid19/weekly-deaths-by-location-age-sex.xlsx',
 sheet_name='Data',
        skiprows=4,
        skipfooter=2,
        usecols='A:F',
        header=None,
        names=['week','location','sex','age','cause','deaths']
        )

data_2021['year'] = data_2021.week.str.slice(0,2).astype(int)
data_2021['week'] = data_2021.week.str.slice(3,5).astype(int)

data_1519 = pd.read_excel ('https://www.nrscotland.gov.uk/files//statistics/covid19/weekly-deaths-by-location-age-group-sex-15-19.xlsx',
        sheet_name='Data',
        skiprows=4,
        skipfooter=2,
        usecols='A:F',
        header=None,
        names=['year','week','location','sex','age','deaths'])

data_1519['cause'] = 'Pre-COVID-19'

data=data_1519.copy()
data=data_1519.append(data_2021,ignore_index=True)
data

One note here. In part 1 I was using a simple assignment od DateFrame for making a copy. This is wrong. Simple assignment does not create a copy of a DataFrame, it just creates another reference to it, so all changes to 2nd DataFrame affects the original one. To make things even more interesting, pandas provides two versions of copy() function - copy(deep=False) and copy(deep=True) deep=True is default. Here is some discussion about all three.

It is not clear for me now what is the difference between simple assignment and shallow copy() (deep=False), nor my results confirm this discussion. But deep copy() (with default parameter deep=True) seems to be working as expected all the time.

Let's see the original data_1519 to ensure we didn't modify it accidently:

data_1519

Let's quickly create totals

totals = data.groupby(['year','week']).agg({'deaths': np.sum})
totals.loc[15,'deaths_15']=totals['deaths']
totals.loc[16,'deaths_16']=totals['deaths']
totals.loc[17,'deaths_17']=totals['deaths']
totals.loc[18,'deaths_18']=totals['deaths']
totals.loc[19,'deaths_19']=totals['deaths']
totals.loc[20,'deaths_20']=totals['deaths']
totals.loc[21,'deaths_21']=totals['deaths']
totals

Read more...

Let's do a little bit of pandas. Pandas is (or are? :) extremely popular. Let's just dive in.

First of all, using pandas is the easiest way to open an Excel file. Just one line of code. Let's take, for example, this file -

Weekly deaths by location of death, age group, sex and cause, 2020 and 2021
(10 March 2021)

weekly-deaths-by-location-age-sex.xlsx

Taken from official the site:

https://www.nrscotland.gov.uk/.../related-statistics

import pandas as pd

data = pd.read_excel ('https://www.nrscotland.gov.uk/files//statistics/covid19/weekly-deaths-by-location-age-sex.xlsx',
 sheet_name='Data',
        skiprows=4,
        skipfooter=2,
        usecols='A:F',
        header=None,
        names=['week','location','sex','age','cause','deaths']
        )

data

Read more...

Prestashop Modules Programming. Bcc outgoing emails

Published Feb. 28, 2021, 8:10 p.m.

What is the idea?

Let's start programming Prestashop modules. Where to start from? Here is the documentation:

https://devdocs.prestashop.com/1.7/modules/

Ok. But what would be our first module? It should be very simple, and yet it should be useful. Here is the idea: add BCC to all outgoing emails, that we will have copies of all outgoing emails in out mailbox. Why would we want it? Actually this is what many people want, just try to google. For newly set up website it may be useful. You may want to see how actually the emails look like. And find all versions of emails which Prestashop sends to your customers. Probably after looking to these emails you would want to change them a bit, maybe remove a default "Powered by Prestashop" line at the bottom... You may want to keep monitoring outgoing emails to ensure that next update of Prestashop do not revert your custom emails to default ones (you probably forgot to check "Keep email templates" while updating Prestashop). Ok, let's do it.

Among Prestashop hooks there is one which can be used for our purpose: actionEmailSendBefore

actionEmailSendBefore

Before sending an email This hook is used to filter the content 
or the metadata of an email before sending it or even prevent its sending

Located in: /classes/Mail.php

https://devdocs.prestashop.com/1.7/modules/concepts/hooks/list-of-hooks/

If we have a chance to look inside /classes/Mail.php file, we will see that all the parameters are prefixed with "&", what means we can change them:

$hookBeforeEmailResult = Hook::exec(
        'actionEmailSendBefore',
        [
            'idLang' => &$idLang,
            'template' => &$template,
            'subject' => &$subject,
            'templateVars' => &$templateVars,
            'to' => &$to,
            'toName' => &$toName,
            'from' => &$from,
            'fromName' => &$fromName,
            'fileAttachment' => &$fileAttachment,
            'mode_smtp' => &$mode_smtp,
            'templatePath' => &$templatePath,
            'die' => &$die,
            'idShop' => &$idShop,
            'bcc' => &$bcc,
            'replyTo' => &$replyTo,
        ],
        null,
        true
    );

I couldn't find any place in Prestashop Back Office which could potentially use BCC. So we can assume (for simplicity) it is always empty and we can just set it to our own value in the hook.

Let's create a module!

Luckily, there is a simple way to create a module skeleton. There is a module generator supplied by Prestashop itself:

https://validator.prestashop.com/generator

I wouldn't say it works perfectly but it works. Probably, it is not updated as ofter as new Prestashop versions are released, and contains some bugs. Also, as mentioned in the documentation, if you want to create a Payments module for Prestashop 1.7 you should not use this generator. But for our purpose it as just invaluable.

So put some values to start a module - name, version, author name etc.:

Read more...

1

2