martedì 17 giugno 2014

Perché rimuovere i layout handle è una cattiva idea?

Ci è capitato di dover lavorare su un progetto sviluppato da una blasonata agenzia straniera e dovendo attivare un secondo tema abbiamo cominciato ad avere problemi di rendering e subito il sospetto è ricaduto su un malfunzionamento della cache dei blocchi.

In realtà il malfunzionamento riguardava la cache dei layout e il motivo è il seguente: la blasonata agenzia ha ben pensato di rimuovere programmaticamente alcuni handle per presunte ottimizzazioni di performance (tutte da dimostrare).

Peccato solo che Magento utilizzi gli handle per generare la cache key dei layout, ecco dove e come:

class Mage_Core_Model_Layout_Update
{
  ...
  public function getCacheId()
  {
    if (!$this->_cacheId) {
      $this->_cacheId = 'LAYOUT_'.Mage::app()->getStore()->getId().md5(join('__', $this->getHandles()));
    }
    return $this->_cacheId;
  }
  ...
}

Per risolvere è bastato aggiungere programmaticamente degli handle per disambiguare i due temi; la soluzione sarebbe quella di eliminare il removeHandle() introdotto dalla blasonata agenzia ma non volendo modificare il loro codice abbiamo preferito così: per fortuna Magento fornisce sempre delle ancore di salvezza.

Spero sia utile: enjoy!

lunedì 17 febbraio 2014

Mai provato con i Convertor?

I convertor sono utilizzati in Magento per copiare dinamicamente dati da una entità all'altra, tipicamente allo scopo di persistere informazioni che potrebbero variare nel tempo. Un classico esempio è il prezzo di un prodotto.

Anche se magari non ce ne rendiamo conto, Magento fa un largo uso dei convertor quando interagiamo con esso. Succede ogni volta che inseriamo un prodotto a carrello come utenti autenticati o quando procediamo attraverso le diverse fasi del checkout fino ad arrivare alla conferma d'ordine.

Ad esempio, quando confermiamo un ordine, Magento effettua le seguenti conversioni:

  • Copia nel carrello i dati del cliente
  • Copia nell'ordine i dati del carrello 
  • Copia negli indirizzi dell'ordine gli indirizzi del carrello
  • Copia i dati di pagamento dal carrello all'ordine
  • Copia gli elementi del carrello in quelli dell'ordine, inclusi gli sconti applicati

Tutto questo in maniera trasparente e guidata, come spesso succede, dalla configurazione.

Il meccanismo alla base dei convertor sta nel metodo copyFieldset() della classe Mage_Core_Helper_Data; tale metodo copia i dati da una entità (o array) sorgente ad una entità (o array) di destinazione; i dati da copiare sono discriminati in base ai parametri attuali del metodo: essi identificano il ramo XML di configurazione di riferimento che mappa i campi dell'entità sorgente in quelli dell'entità destinazione.

Per tornare all'esempio della conferma d'ordine, se l'ordine è confermato da frontend allora sarà invocato il metodo Mage_Sales_Model_Service_Quote::submitOrder() che a sua volta richiama diversi metodi sull'oggetto protetto $_convertor di tipo Mage_Sales_Model_Convert_Quote; i metodi richiamati su $_convertor a loro voltano invocano la Mage_Core_Helper_Data::copyFieldset() citata in precedenza e il cerchio si chiude. Un esempio di ramo di configurazione utilizzato nel giro della conferma d'ordine è il seguente, tratto dal config.xml del modulo Mage_Sales:

<?xml version="1.0"?>
<config>
  ...
  <global>
    ...
    <fieldsets>
      ...
      <sales_convert_quote>
        <remote_ip>
          <to_order>*</to_order>
        </remote_ip>
        <x_forwarded_for>
          <to_order>*</to_order>
        </x_forwarded_for>
        <customer_id>
          <to_order>*</to_order>
        </customer_id>
        <customer_email>
          <to_order>*</to_order>
        </customer_email>
        ...
        <items_qty>
          <to_order>total_qty_ordered</to_order>
        </items_qty>
      </sales_convert_quote>
      ...
    </fieldsets>
    ...
  </global>
  ...
</config>

Il carattere asterisco (*) indica che il nome del campo dell'entità destinazione è lo stesso di quello dell'entità sorgente; mentre invece il campo items_qty dell'entità Quote è mappato nel campo total_qty_ordered dell'entità Order.

Se estendessimo una entità di Magento, ad esempio l'Ordine, e volessimo persistere in esso una informazione presente a Carrello (o in un Prodotto, pensate ad un custom attribute il cui valore è variabile nel tempo come un prezzo), ci basterebbe aggiungere una configurazione che mappa i dati che vogliamo copiare a seconda del fieldset di riferimento.

Una caratteristica interessante del metodo Mage_Core_Helper_Data::copyFieldset() è il fatto che, al termine delle operazioni di copia, effettua il dispatch di un evento al quale è possibile agganciarsi per poter accodare le proprie logiche di copia di dati su tabelle custom, senza necessariamente andare ad estendere entità native di Magento, soprattutto se queste non sono basate sul modello EAV ma richiederebbero delle modifiche strutturali alle tabelle core.

Enjoy!

mercoledì 27 novembre 2013

Watermark su immagini di Categoria.

Nell'articolo seguente illustriamo un modo per aggiungere un Watermark sull'immagine di una Categoria.

L'idea di base è quella di agganciare l'evento di caricamento della Categoria a livello frontend e applicare a runtime il Watermark all'immagine della Categoria.

Non agganciamo l'evento anche a livello adminhtml per evitare l'applicazione del Watermark anche lato Admin Panel.

La soluzione presentata al momento non applica nessuna logica di caching, sebbene l'immagine generata sia scritta su file system in una sotto cartella di media/catalog/category/cache.

L'immagine a cui applichiamo il Watermark non viene cancellata da una eventuale cancellazione della Categoria e non collide con quelle di altre categorie perché nel nome contiene l'ID della categoria stessa.

Per la parte di configurazione abbiamo sfruttato il meccanismo di cloning delle config già utilizzato dalle opzioni relative al Watermark sui Prodotti.
Attuando un rewrite del Model adminhtml/system_config_clone_media_image otteniamo così l'insieme di campi necessari alla configurazione delle modalità di applicazione del Watermark alla immagine di Categoria.

La porzione di config.xml è riportata di seguito:

<config>
  ...
  <global>
  ....
    <models>
    ...
    <adminhtml>
      <rewrite>
        <system_config_clone_media_image>Gbinside_Catwatermark_Model_Clone</system_config_clone_media_image>
     </rewrite>
     </adminhtml>
     ...
   <models>
   ...
  </global>
  ...
</config>

La classe Gbinside_Catwatermark_Model_Clone è implementata come segue:

class Gbinside_Catwatermark_Model_Clone 
  extends Mage_Adminhtml_Model_System_Config_Clone_Media_Image 
{
  public function getPrefixes()
  {
    $prefixes = parent::getPrefixes();
    $prefixes[] = array(
      'field' => 'category_',
      'label' => 'Category',
    );
    return $prefixes;
  }
}

Il metodo getPrefixes() invoca il metodo parent che restituisce una array di array a cui accoda la propria parte di configurazione. Troveremo queste opzioni di configurazione in System > Configuration > GENERAL > Design > Product Image Watermarks.

Si è scelto di inserire qui tali opzioni qui per mantenere la gestione centralizzata dei Watermark anche se nella voce di menu si fa riferimento a Prodotti e non a Categorie.

L'applicazione del Watermark è demandata ai metodi della classe Varien_Image, utilizzati agganciando l'evento catalog_category_load_after tramite la configurazione seguente:

<config>
  ...
  <frontend>
    ...
    <events>
      ....
      <catalog_category_load_after>
        <observers>
          <gbinside_catwatermark>
            <type>singleton</type>
            <class>Gbinside_Catwatermark_Model_Observer</class>
            <method>WatermarkCategory</method>
          </gbinside_catwatermark>
        </observers>
      </catalog_category_load_after>
      ...
    </events>
    ....
  </frontend>
</config>

L'implementazione del metodo WatermarkCategory() nella classe Gbinside_Catwatermark_Model_Observer è riportata di seguito:

public function WatermarkCategory($observer)
{
  /** @var Mage_Catalog_Model_Category $_category */
  $_category = $observer->getDataObject();

  if ($_category->getImage()) {
    $_filename = Mage::getBaseDir('media') . '/catalog/category/' . $_category->getImage();
    $_varienImage = new Varien_Image($_filename);
    $_image = Mage::getSingleton('catalog/product_media_config')->getBaseMediaPath() 
      . '/watermark/' 
      . Mage::getStoreConfig("design/watermark/category_image");
    $size = $this->_parseSize(Mage::getStoreConfig("design/watermark/category_size"));
    if ($size) {
      $_varienImage->setWatermarkHeigth($size['heigth']);
      $_varienImage->setWatermarkWidth($size['width']);
    }
    $_varienImage->setWatermarkPosition(Mage::getStoreConfig("design/watermark/category_position"));
    $_varienImage->setWatermarkImageOpacity(Mage::getStoreConfig("design/watermark/category_imageOpacity"));
    $_varienImage->watermark($_image);
    $_finalFilename = 'cache/catimage_' . $_category->getId() . '.jpg';
    $_varienImage->save(Mage::getBaseDir('media') . '/catalog/category/' . $_finalFilename);
    $_category->setImage( $_finalFilename );
  }
}

L'evento è dispacciato ogni volta che Magento carica una Categoria ma l'immagine viene processata solo se la Categoria ne ha una associata.

Una volta generata l'immagine con il Watermark, la salviamo sul disco e la sostituiamo come riferimento nell'attributo image della categoria.
Questo valore non è persistito a DB a meno che la Categoria non venga salvata lato frontend, il che normalmente non avviene.

Alcune possibili migliorie:

  • introduzione di un meccanismo di caching;
  • un flag a livello di Categoria per specificare se applicare o meno il Watermark;
  • applicazione del Watermark anche sulla Thumbnail.

Il codice completo del modulo è disponibile su GitHub al seguente indirizzo:
https://github.com/gbinside/gbinside_catwatermark/

giovedì 11 luglio 2013

Cancellare un Prodotto... davvero

Quando si cancella un Prodotto da Magento Admin Panel, le eventuali immagini associate in Galleria non vengono rimosse dal File System, il che, a lungo andare, può comportare un consumo inutile di spazio disco.

Per il risolvere il problema basta un semplice Observer che si attivi sull'evento di cancellazione del Prodotto, ne recuperi le immagini e le cancelli.

L'evento è il catalog_product_delete_commit_after; utilizziamo questo evento per essere sicuri di eliminare i file solo se  la cancellazione è effettivamente andata a buon fine.

Di seguito riportiamo il codice; è il classico esempio in cui si scrive più XML che PHP :-)

File di attivazione modulo in <magedir>/app/etc/modules/

<?xml version="1.0"?>
<config>
    <modules>
        <Webgriffe_ProductDelete>
            <active>true</active>
            <codePool>local</codePool>
        </Webgriffe_ProductDelete>
    </modules>
</config>

File di configurazione in <magedir>/app/code/local/Webgriffe/ProductDelete/etc/

<?xml version="1.0"?>
<config>
    <modules>
        <Webgriffe_ProductDelete>
            <version>1.0.0.</version>
        </Webgriffe_ProductDelete>
    </modules>
    <global>
        <models>
            <wgproddel>
                <class>Webgriffe_ProductDelete_Model</class>
            </wgproddel>
        </models>
        <events>
            <catalog_product_delete_commit_after>
                <observers>
                    <wgproddel_after_delete>
                        <type>singleton</type>
                        <class>Webgriffe_ProductDelete_Model_Observer</class>
                        <method>onProductDeleteAfter</method>
                    </wgproddel_after_delete>
                </observers>
            </catalog_product_delete_commit_after>
        </events>
    </global>
</config>

Observer in <magedir>/app/code/local/Webgriffe/ProductDelete/Model/

<?php
class Webgriffe_ProductDelete_Model_Observer
{
    public function onProductDeleteAfter($observer)
    {
        $product = $observer->getEvent()->getDataObject();

        Mage::log('Observed a delete after commit on product ' . $product->getId());
        if ($product->getId()) {
            #$images = $product->getMediaGalleryImages();
            $images = $product->getMediaGallery('images');
            foreach ($images as $image) {
                $imageFile = Mage::getBaseDir('media').DS.'catalog'.DS.'product'.$image['file'];
                if (@unlink($imageFile)) {
                    Mage::log('Deleted Product Image File "' . $imageFile . '"');
                } else {
                    Mage::log('Could not deleted Product Image File "' . $imageFile . '"');
                }
            }
        }
    }
}

giovedì 4 luglio 2013

Come creare un Admin User Role programmaticamente

In attesa che Mageploy implementi un tracker per la creazione degli Admin User Role, vediamo come farlo programmaticamente.

Innanzitutto occorre sapere quali sono le chiavi dei permessi accordare all'utente e per avere un elenco completo senza ricostrurselo manualmente spulciando le ACL nei diversi file di configurazione di Magento si può eseguire il seguente codice:

$acl = Mage::getModel('admin/acl');
$resources = Mage::getSingleton('admin/config')->loadAclResources($acl);
print_r($acl->getResources());

Il risultato sarà un Array del tipo 

Array
(
    [0] => all
    [1] => admin
    [2] => admin/dashboard
    [3] => admin/system
    [4] => admin/system/acl
    [5] => admin/system/acl/roles
    [6] => admin/system/acl/users
    [7] => admin/system/store
    ...
    [198] => admin/xmlconnect/mobile
    [199] => admin/xmlconnect/history
    [200] => admin/xmlconnect/templates
    [201] => admin/xmlconnect/queue
)

Supponiamo di voler creare un Admin User che abbia accesso solo alla gestione Ordini e a quella del proprio Account, le chiavi che ci interessano sono le seguenti:
  • admin/sales
  • admin/sales/order
  • admin/sales/order/actions
  • admin/sales/order/actions/create
  • admin/sales/order/actions/view
  • admin/sales/order/actions/email
  • admin/sales/order/actions/reorder
  • admin/sales/order/actions/edit
  • admin/sales/order/actions/cancel
  • admin/sales/order/actions/review_payment
  • admin/sales/order/actions/capture
  • admin/sales/order/actions/invoice
  • admin/sales/order/actions/creditmemo
  • admin/sales/order/actions/hold
  • admin/sales/order/actions/unhold
  • admin/sales/order/actions/ship
  • admin/sales/order/actions/comment
  • admin/sales/order/actions/emails
  • admin/system
  • admin/system/myaccount
Per creare il Ruolo:

$role = Mage::getModel('admin/role')
    ->setParentId(0)
    ->setTreeLevel(1)
    ->setSortOrder(0)
    ->setRoleType(Mage_Admin_Model_Acl::ROLE_TYPE_GROUP)
    ->setUserId(0)
    ->setRoleName('My Test Role')
    ->save();

Vediamo il significato degli attributi del Model Role equivalenti alle colonne della tabella admin_role:
  • parent_id: è sempre valorizzato a 0 per i Ruoli di tipo 'G' (cioè Gruppo di Permessi) mentre per i Ruoli di tipo 'U' (Utenti) serve a definire quale Ruolo è associato al determinato Utente.
  • tree_level: è sempre valorizzato a 1 per i Ruoli di tipo 'G' e a 2 per i Ruoli di tipo 'U'.
  • sort_order: rappresenta un campo di ordinamento.
  • role_type: serve a distinguere i Ruoli che rappresentano un Gruppo di Permessi (tipo 'G') da quelli che rappresentano l'associazione con gli Utenti (tipo 'U').
  • user_id: è sempre valorizzato a 0 per i Ruoli di tipo 'G' mentre per i Ruoli di tipo 'U' rappresenta la chiave straniera che collega l'Utente (tabella admin_user).
  • role_name: rappresenta semplicemente il nome da assegnare al Ruolo.
Per assegnare i Permessi:

if ($role->getId()) {
    $resource = array(
        'admin/sales',
        'admin/sales/order',
        'admin/sales/order/actions',
        'admin/sales/order/actions/create',
        'admin/sales/order/actions/view',
        'admin/sales/order/actions/email',
        'admin/sales/order/actions/reorder',
        'admin/sales/order/actions/edit',
        'admin/sales/order/actions/cancel',
        'admin/sales/order/actions/review_payment',
        'admin/sales/order/actions/capture',
        'admin/sales/order/actions/invoice',
        'admin/sales/order/actions/creditmemo',
        'admin/sales/order/actions/hold',
        'admin/sales/order/actions/unhold',
        'admin/sales/order/actions/ship',
        'admin/sales/order/actions/comment',
        'admin/sales/order/actions/emails',        
        'admin/system',
        'admin/system/myaccount',
    );

    Mage::getModel("admin/rules")
        ->setRoleId($role->getId())
        ->setResources($resource)
        ->saveRel();
}

mercoledì 19 giugno 2013

Prestazioni: ancora un occhio ai dettagli

Nell'articolo intitolato "Prestazioni: un occhio ai dettagli" avevamo visto come si possano incrementare le prestazioni adottando delle accortezze durante il salvataggio di un Prodotto su Magento.

In questo articolo mostriamo un altro esempio di ottimizzazione non così scontato che riguarda il caricamento di un Prodotto.

Può capitare che occorra caricare numerosi Prodotti di Magento. Il caricamento di un Prodotto tramite i Model di Magento può essere fatto in due modi: attraverso la load del Product Model o attraverso l'utilizzo della relativa Collection.

Vediamo di seguito i due esempi:

# Caricamento diretto
function getFullProductById($id) {
  return Mage::getModel('catalog/product')
    ->load($id);
}

# Caricamento attraverso Collection
function getLightProductById($id) {
  return Mage::getModel('catalog/product')
    ->getCollection()
    ->addAttributeToFilter('entity_id', $id)
    ->getFirstItem();
}

A livello di risultato, la principale differenza tra le due funzioni è che la prima restituisce il Prodotto completamente valorizzato mentre la seconda restituisce il Prodotto valorizzato con i soli attributi di base elencati di seguito:


  • entity_id
  • entity_type_id
  • attribute_set_id
  • type_id
  • sku
  • has_options
  • required_options
  • created_at
  • updated_at
  • stock_item (Array)

La differenza a livello di prestazioni è però significativa; di seguito il risultato di un ciclo di load di 500 prodotti e tracciato con il Varien Profiler:



getFullProductById: Array(
    [start] => 
    [count] => 1
    [sum] => 9.4332180023193 (secondi)
    [realmem] => 19660800 (bytes)
    [emalloc] => 19696332 (bytes)
    [realmem_start] => 8912896 (bytes)
    [emalloc_start] => 8640796 (bytes)
)

getLightProductById: Array
(
    [start] => 
    [count] => 1
    [sum] => 1.7760589122772 (secondi)
    [realmem] => 3670016 (bytes)
    [emalloc] => 3849780 (bytes)
    [realmem_start] => 8912896 (bytes)
    [emalloc_start] => 8640796 (bytes)
)

Dai dati rilevati emerge che la funzione getLightProductById() è circa cinque volte più veloce e utilizza circa un quinto della memoria rispetto alla getFullProductById().

Se di un prodotto non occorrono tutti gli attributi ma bastano quelli base o ne occorrono pochi e specifici (in seguito vediamo come recuperarli), il secondo approccio, su larga scala, consente di ottenere prestazioni decisamente migliori.

Come dicevamo, se occorrono uno o più attributi specifici, è possibile modificare la funzione Light nel modo seguente:


function getLightProductById($id, $attributesToSelect = array(), $joinType = false) {
  return Mage::getModel('catalog/product')
    ->getCollection()
    ->addAttributeToSelect($attributesToSelect, $joinType)
    ->addAttributeToFilter('entity_id', $id)->getFirstItem();
}


Attenzione, però: per ogni attributo che si sceglie di aggiungere si otterrà un degrado delle performance. Aggiungendo tutti gli attributi la funzione getLightProductById() diventerà meno performante della getFullProductById().

Di seguito un esempio di misurazione dei tempi sul ciclo di riferimento con cinque attributi aggiuntivi; seppur sempre più conveniente della versione Full, si noti come le risorse impegnate si avvicinano a quelle della Light:


getLightProductById con cinque attributi: Array
(
    [start] => 
    [count] => 1
    [sum] => 6.2210462093353
    [realmem] => 4718592
    [emalloc] => 4794228
    [realmem_start] => 8912896
    [emalloc_start] => 8641496
)

martedì 11 giugno 2013

Un modulo open source per attivare la modalità Maintenance

Tempo fa su questo blog abbiamo pubblicato un srticolo relativo alla possibilità di utilizzare un Custom Router per creare una pagina di Maintenance.

Da quella idea è nato il modulo open source Webgriffe_Maintenance che abbiamo recentemente rilasciato su GitHub.

Oltre ad utilizzarlo per mostrare una pagina di cortesia durante gli aggiornamenti del sito, il modulo  può anche essere utilizzato per inibire l'accesso ad estranei durante la fase di sviluppo, poiché consente di filtrare l'ingresso in base all'indirizzo IP del client, qualora quest'ultimo sia statico.

Il modulo non è stato testato in presenza di meccanismi di reverse proxy com Varnish ed è stato sviluppato su Magento CE v. 1.7.0.2.

Speriamo lo troviate utile e che possa fornire nuovi spunti di sviluppo.