Grazie alle API XML-RPC, possiamo far dialogare uno script Python con WordPress e fare cose veramente interessanti come modificare il CMS a partire da file Excel. È il caso di un cliente che doveva aggiornare il suo catalogo on-line periodicamente partendo da alcuni fogli Excel generati dal suo gestionale interno. Tali aggiornamenti, in media bimestrali o trimestrali, comprendono:
- inserimento di nuovi prodotti;
- rimozioni di prodotti non più a catalogo;
- modifica di dati relativi al prodotto e/o ai suoi accessori;
- categorizzazione dei prodotti;
- spostamento di prodotti da una categoria all'altra;
- inserimento di nuove categorie o rimozione di categorie non più utilizzate;
- definizione di varianti di prodotto;
- assegnazione dei prodotti a più target di utilizzo (un martello interessa sia ad un muratore che ad un falegname);
- assegnazione di manuali tecnici e/o brochure commerciali ad uno o più prodotti e/o varianti;
- gestione dell'ordinamento dei prodotti (desunto dall'ordinamento all'interno del foglio Excel).
Vista la mole di dati da aggiornare e l'impossibilità di rilevare quali modifiche siano state apportate ai fogli excel periodicamente forniti, è stato proposto al cliente che ogni aggiornamento consistesse in una rimozione di tutta la gerarchia delle categorie/prodotti e nella successiva ricostruzione ex-novo partendo dai dati contenuti nei fogli Excel, a patto che tale 'ricostruzione' del catalogo non richiedesse più di trenta minuti di "disservizio" ad ogni aggiornamento.
L'esiguità del tempo concesso per l'aggiornamento ha ovviamente escluso la possibilità di inserimento, modifica o rimozione manuale dei dati e dei file allegati e ha portato a trovare una soluzione programmatica alla necessità. La scelta del linguaggio utilizzato per la realizzazione dello script di importazione dei dati è ricaduta su Python per la concisione del codice, la vasta gamma di librerie disponibili e l'elevata capacità di maneggiare strutture dati complesse con efficienza sintattica.
Quelli che seguono sono esempi di codice prelevati direttamente dallo script di importazione ed editati in modo da renderli il più semplice possibile da studiare o riutilizzare.
Lettura dei fogli di Excel
Per affrontare la fase della lettura dei fogli di Excel si è fatto ricorso alla libreria xlrd. Il foglio Excel contiene numerose colonne, tuttavia noi siamo interessati solamente ad alcune, necessario inoltre gestire l'eventualità che alcune delle colonne siano state spostate e/o scambiate di posto con altre; infine, l'organizzazione del foglio Excel prevede che il valore di una colonna ("PRODOTTO") abbia il medesimo valore per tutti gli articoli che compongono il prodotto (una sorta di distinta base).
#!/usr/bin/env python2
# coding: utf-8
from decimal import Decimal
from pprint import pprint
from xlrd import open_workbook
import sys
#
# dizionario contenente la posizione di ogni colonna all'interno del file excel
# i nomi delle colonne sono definiti nella prima riga del foglio excel e devono
# essere identici ai nomi qui riportati. Eventuali colonne aggiuntive saranno
# semplicemente ignorate
#
XL_COLUMNS = {
'PRODOTTO': -1,
'DES PRODOTTO': -1,
'TIPO': -1,
'ARTICOLO': -1,
'DES ITA': -1,
'DES ENG': -1,
'QTY': -1,
'EUR ARTICOLO': -1
}
# --
def stringa( cell ):
"""
Ritorna il contenuto di una cella come stringa
"""
if cell.ctype == 2:
return "%d" % cell.value
else:
return cell.value.strip()
# --
def intero( cell ):
"""
Ritorna il contenuto di una cella come intero
Ritorna None se la cella non è numerica
"""
if cell.ctype == 2:
return int( cell.value )
else:
return None
# --
def decimale( cell ):
"""
Ritorna il contenuto di una cella come valore con 2 cifre decimali
Ritorna None se la cella non è numerica
"""
if cell.ctype == 2:
return Decimal( str( cell.value ) ).quantize(Decimal('1.00'))
else:
return None
# --
if __name__ == '__main__':
#
# recupero posizioni colonne
#
xl = open_workbook('listino.xls')
sheet = xl.sheet_by_index(0)
for col in range( sheet.ncols):
XL_COLUMNS[ stringa( sheet.cell(0,col) ) ] = col
if -1 in XL_COLUMNS.values():
print "Intestazioni colonne mancanti alla riga 1:"
for k,v in XL_COLUMNS.items():
if v == -1:
print k
sys.exit( 1 )
#
# recupero gerarchia prodotti/dotazioni/accessori/opzioni/servizi
#
products = {}
valid_article_codes = []
for row in range(1, sheet.nrows):
product_code = stringa( sheet.cell(row, XL_COLUMNS['PRODOTTO']) ).upper()
article_code = stringa( sheet.cell(row, XL_COLUMNS['ARTICOLO']) ).upper()
article_type = stringa( sheet.cell(row, XL_COLUMNS['TIPO']) )
descr_italian = stringa( sheet.cell(row, XL_COLUMNS['DES ITA']) )
descr_english = stringa( sheet.cell(row, XL_COLUMNS['DES ENG']) )
qty = intero( sheet.cell(row, XL_COLUMNS['QTY']) )
price = decimale( sheet.cell(row, XL_COLUMNS['EUR ARTICOLO']) )
valid_article_codes.append( article_code )
#
#
#
if not product_code and not article_type:
continue
if product_code not in products:
products[ product_code ] = {
'accessories': [],
'options': [],
'services': [],
'versions': [],
'dotations': [],
'targets': [],
'ordinamento': row,
}
if product_code == article_code: # Prodotto Principale
products[ product_code ]['descr_italian'] = descr_italian
products[ product_code ]['descr_english'] = descr_english
products[ product_code ]['price'] = price
elif article_type == 'A': # Accessorio
products[ product_code ]['accessories'].append({
'article_code': article_code,
'descr_italian': descr_italian,
'descr_english': descr_english,
'price': price,
})
elif article_type == 'D': # Dotazione
if 'codice sconto' not in descr_italian.lower():
products[ product_code ]['dotations'].append({
'article_code': article_code,
'descr_italian': descr_italian,
'descr_english': descr_english,
'qty': qty,
})
elif article_type == 'O': # Opzione
products[ product_code ]['options'].append({
'article_code': article_code,
'descr_italian': descr_italian,
'descr_english': descr_english,
'price': price,
})
elif article_type == 'S': # Servizio
products[ product_code ]['services'].append({
'article_code': article_code,
'descr_italian': descr_italian,
'descr_english': descr_english,
'price': price,
})
elif article_type == 'V': # Versione
products[ product_code ]['versions'].append({
'article_code': article_code,
'descr_italian': descr_italian,
'descr_english': descr_english,
'price': price,
})
#
# stampa la struttura dati ottenuta dal file excel
#
pprint( products )
Fatto questo, il prossimo passaggio sarà quello relativo al recupero delle informazioni relative ai media da associare come per esempio documenti PDF e immagini.
Recupero informazioni relative ai media da associare (PDF e immagini)
Le immagini e i file PDF relative agli articoli, vengono caricate e/o sostituite tramite il media manager di WordPress. Tutti i file da associare agli articoli devono possedere un nome composto secondo lo schema: <codice articolo>[-<testo libero>].[jpg|gif|png]
. Fanno eccezione i file PDF, che sono invece indicizzati per nome e associati tramite colonne specifiche nel file XLS (associazione qui non mostrata), poiché molteplici prodotti possono fare riferimento al medesimo file PDF. Per cui se ad esempio il codice del nostro articolo è 'ABCD', i seguenti file saranno associati con successo:
ABCD.png
ABCD.gif
ABCD-fronte.png
ABCD-retro.png
ABCD-vista-frontale-ingrandita.jpg
Mentre i seguenti file non saranno associati:
logo.png
Lo scopo del codice seguente è quello di creare un semplice elenco dei file associati ad ogni articolo conosciuto:
#
# parametri di connessione al server
#
WP_URL = 'http://www.mywpsite.com/xmlrpc.php' # url del sito + /xmlrpc.php
WP_USERNAME = 'a-user-wp' # utilizzare una username reale
WP_PASSWORD = 'fp*********]h' # utilizzare una password reale
WP_BLOGID = ''
WP_STATUS_DRAFT = 0
WP_STATUS_PUBLISHED = 1
#
# connessione XML-RPC al server WordPress
#
server = xmlrpclib.ServerProxy( WP_URL )
#
# recupero informazioni media
#
images = {}
pdf = {}
media_library = []
media_offset = 0
media_sublibrary = []
#
# richiediamo i dati a WordPress, 100 media per volta fino al
# completamento dell'elenco dei media
#
while not media_offset or media_sublibrary:
media_library.extend( media_sublibrary )
media_sublibrary = server.wp.getMediaLibrary( WP_BLOGID, WP_USERNAME, WP_PASSWORD, {'number': 100, 'offset': media_offset} )
media_offset += 100
#
# analizziamo il nome del media ed eventualmente memorizziamo l'id
#
for media in media_library:
#
# trova l'id articolo corrispondente
#
article_code = media['link'].split('/')[-1]
if article_code.endswith( '.pdf' ):
pdf[ article_code ] = media['attachment_id']
continue
while article_code != '' and article_code not in valid_article_codes:
article_code = '-'.join( article_code.split('-')[:-1] )
if not article_code: # codice articolo sconosciuto
continue
#
# match trovato, memorizza il link in pdf[] o in images[]
#
if media['link'].lower().endswith( '.pdf' ):
if article_code not in pdf:
pdf[ article_code ] = []
pdf[ article_code ].append( media['attachment_id'] )
elif media['link'].lower()[-4:] in [ '.jpg', '.gif', '.png' ]:
if article_code not in images:
images[ article_code ] = []
images[ article_code ].append( media['attachment_id'] )
Indicizzazione delle categorie prodotto
Indicizziamo le categorie definite come terms
all'interno della tassonomia, in modo di potere specificare la giusta categoria al momento della creazione di ogni prodotto.
WP_TAXONOMY = 'categorie-prodotti'
#
# indicizzazione categorie
#
wp_cat_ids = {}
wp_cat = server.wp.getTerms( WP_BLOGID, WP_USERNAME, WP_PASSWORD, WP_TAXONOMY )
if not wp_cat:
print "ERROR: no categories in", WP_TAXONOMY
sys.exit( 1 )
for cat in wp_cat:
if cat['parent'] == '0':
wp_cat_ids[ cat['slug'] ] = {
'id': cat['term_id'],
'children': {}
}
for subcat in wp_cat:
if subcat['parent'] == cat['term_id']:
wp_cat_ids[ cat['slug'] ]['children'][ subcat['slug'] ] = subcat['term_id']
Eliminazione dei dati obsoleti
All'interno dello script originale vengono eseguiti ulteriori check e associazioni di dati (presenza di almeno una immagine per ogni prodotto, associazione del prodotto con le categorie e le destinazioni d'uso ed altro..). Una volta terminate tutte le associazioni e le verifiche non distruttive dei dati, possiamo procedere con l'importazione vera e propria, preceduta dalla cancellazione totale dei dati ormai obsoleti. Per evitare di cancellare un prodotto padre prima dei relativi figli, verranno eliminati i prodotti in ordine inverso di id
:
old_products = []
old_offset = 0
old_subset = []
#
# otteniamo l'elenco di prodotti da eliminare
#
while not old_offset or old_subset:
old_products.extend( old_subset )
old_subset = server.wp.getPosts( WP_BLOGID, WP_USERNAME, WP_PASSWORD, {
'post_type': 'prodotti-catalogo',
'number': 100,
'offset': old_offset
} )
old_offset += 100
old_products = sorted( old_products, key=lambda product:product['post_id'], reverse=True )
for product in old_products:
server.wp.deletePost( WP_BLOGID, WP_USERNAME, WP_PASSWORD, product['post_id'] )
Il prossimo step sarà quindi quello riguardante l'inserimento dei nuovi prodotti a cui seguirà il collegamento delle configurazioni ai prodotti padre.
Inserimento dei nuovi prodotti
A questo punto abbiamo eliminato tutti i prodotti, le categorie, i target (destinazioni d'uso) e i media (immagini e PDF) non sono stati toccati quindi, ad esempio, i link nei menu che porteranno all'elenco dei prodotti di una specifica categoria o con una specifica destinazione d'uso sono ancora perfettamente funzionanti. Iniziamo ad importare i nuovi dati, seguendo l'ordine del foglio Excel:
LANG = 'IT'
sorted_products = sorted( [ item for item in products.items() ], key=lambda p: p[1]['ordinamento'], reverse=True )
for product_code, product in sorted_products:
slug = product['category'].lower().replace('+','').replace(' ', '-').replace(' ','-') + '-' + LANG.lower()
subslug = product['subcat'].lower().replace('+','').replace(' ', '-').replace(' ','-') + '-' + LANG.lower()
if slug == subslug:
ids = [ wp_cat_ids[ slug ]['id'] ]
else:
ids = [ wp_cat_ids[ slug ]['id'], wp_cat_ids[ slug ]['children'][ subslug ] ]
data = {
'post_title': product['descr_' + {'IT':'italian','EN':'english'}[LANG] ],
'post_type': 'prodotti-cardioline',
'post_content': make_content( LANG, product ),
'terms': { WP_TAXONOMY: ids },
'post_status': 'publish',
'custom_fields': [],
}
if product_code in images and images[ product_code ]:
data['post_thumbnail'] = images[ product_code ][0]
else:
data['post_thumbnail'] = 9 # immagine assente, la sostituiamo con il logo dell'azienda ( id=9 )
id_doc = 1
data['custom_fields'] = []
for document in product['documents']: # product['documents'] = elenco dei file PDF da associare
if id_doc > 6: # max 6 documenti
continue
if document.startswith( 'qrg_' ):
titolo = { 'IT':'Guida Rapida','EN':'Quick Reference Guide' }[ LANG ]
else:
titolo = { 'IT':'Scheda Prodotto','EN':'Data Sheet' }[ LANG ]
media_id = pdf.get( document.replace(' ','-').replace('+','-').replace('--','-'), 0 )
if media_id:
data['custom_fields'].append( { 'key': 'titolo_allegato_%d' % id_doc, 'value': titolo } )
data['custom_fields'].append( { 'key': 'allegato_%d' % id_doc, 'value': media_id } )
id_doc += 1
data['custom_fields'].append( {
'key': 'target',
'value': product['targets'] # product['targets'] = elenco destinazioni d'uso
})
data['custom_fields'].append( {
'key': 'ordinamento',
'value': product['ordinamento']
})
product['wp_id'] = server.wp.newPost( WP_BLOGID, WP_USERNAME, WP_PASSWORD, data, WP_STATUS_PUBLISHED )
Collegamento delle configurazioni (varianti) ai prodotti padre
I prodotti possiedono un custom field
chiamato "configurazioni" che contiene l'elenco degli id dei prodotti che sono varianti del prodotto padre. Dopo che abbiamo inserito tutti i prodotti ed ottenuto il relativo id di WordPress, possiamo procedere con l'aggiornamento dei prodotti padre ed inserire l'elenco dei prodotti figli. L'elenco delle configurazioni è stato ottenuto da un altro file Excel ed inserito nella chiave configurations
di ogni prodotto come una lista di codici prodotto:
for product_code, product in products.items():
if product.get( 'configurations', None ) == None:
continue
configurations = []
for configuration in product['configurations']:
configurations.append( products[ configuration ]['wp_id'])
server.wp.editPost( WP_BLOGID, WP_USERNAME, WP_PASSWORD, product['wp_id'], {
'custom_fields': [ {
'key': 'configurazioni',
'value': configurations
} ]
}
A questo punto il nostro compito è terminato ed abbiamo aggiornato il catalogo. Lo script originale, che esegue molti più controlli di quelli qui riportati, importa 100 prodotti con relative associazioni a categoria, sottocategoria, target, documenti PDF, immagini in un tempo mai superiore ai 3 minuti. Come è possibile notare, i comandi XMLRPC utilizzati sono veramente pochi ed essendo XMLRPC un protocollo standard, è possibile implementare il codice in un qualsiasi altro linguaggio scelto dallo sviluppatore.
I comandi utilizzati sono:
Argomento | Metodo |
---|---|
server | xmlrpclib.ServerProxy( WP_URL ) |
media | server.wp.getMediaLibrary( WP_BLOGID, WP_USERNAME, WP_PASSWORD, { 'number': 100, 'offset': offset } ) |
categories | server.wp.getTerms( WP_BLOGID, WP_USERNAME, WP_PASSWORD, WP_TAXONOMY ) |
term_id | server.wp.newTerm( WP_BLOGID, WP_USERNAME, WP_PASSWORD, data) |
status | server.wp.deleteTerm( WP_BLOGID, WP_USERNAME, WP_PASSWORD, WP_TAXONOMY, term_id ) |
subset | server.wp.getPosts( WP_BLOGID, WP_USERNAME, WP_PASSWORD, { 'post_type': 'prodotti-catalogo', 'number': 100, 'offset': offset } ) |
post_id | server.wp.newPost( WP_BLOGID, WP_USERNAME, WP_PASSWORD, data, WP_STATUS_PUBLISHED ) |
status | server.wp.editPost( WP_BLOGID, WP_USERNAME, WP_PASSWORD, post_id, modified_data ) |
status | server.wp.deletePost( WP_BLOGID, WP_USERNAME, WP_PASSWORD, post_id ) |
Riferimenti disponibili tramite il Codex di WordPress:
Un ringraziamento dell'autore va ad Andrea Papotti per l'implementazione del codice Python.