Caricamento dati su Openproject con Python usando le API REST

Di recente ho fatto qualche esperimento con Openproject, versione community, installata sulla mia VPS. È uno strumento molto interessante e con notevoli potenzialità (pur con tutte le limitazioni della versione free).

Un grosso ostacolo che ho incontrato però è stato il caricamento dei work package (come sono definite le singole attività all'interno di un progetto) da una lista esterna. Il caso tipico è che la lista dei task di un progetto viene costruita sotto forma di excel condiviso, la forma più adatta per il tira-e-molla tra cliente e consulente/i nella fase iniziale. Quando però la lista è approvata ed è ora di mettere in ordine le attività, mi rifiuto di inserire a manina 150 work package uno a uno nell'interfaccia web, facendo copia e incolla da excel, non siamo mica nel medioevo!

Openproject fornisce una manciata di soluzioni poco interessanti per il caricamento massivo di dati, importandoli da altri software di project management o utilizzando un foglio excel ad hoc che contiene una macro orribile che fa infartuare l'antivirus del mio pc.

Fortunatamente però Openproject ha un'ottima API REST che copre tutte le operazioni di gestione dei progetti e dei work package, e quindi con un pò di Python possiamo costruirci il nostro bel sistema di caricamento su misura!


Permessi accesso

Prima di cominciare con gli sviluppi, è necessario configurare la nostra installazione di Openproject per consentire l'accesso mediante OAuth. Si va nella finestra di Amministrazione (dal menu che si ottiene cliccando l'avatar in alto a destra, ovviamente dobbiamo essere loggati con un utente amministrativo). Si procede su Autenticazione->Impostazioni e verso la fine della pagina si verifica che il servizio WEB REST sia attivo.

Poi si va su Autenticazione-> Applicazioni Oauth e si aggiunge una nuova applicazione.

Il nome è solo descrittivo, qualunque cosa va bene. Per il reindirizzamento url possiamo usare il valore suggerito sotto il form. Scopes: api v3. Come "Client Credentials User" impostiamo direttamente il nostro utente (l'utente che risulterà creatore dei work package che andremo a inserire).

Prendiamo nota di tutti i parametri mostrati nella schermata, in particolar modo della chiave segreta (che non potrà più essere mostrata una volta usciti da questa schermata).


Autenticazione con Python

Per prima cosa dobbiamo effettuare l'autenticazione mediante OAuth. A seguito di questa autenticazione otterremo un token, che dovremo usare nell'header di tutte le richieste successive.

Per prima cosa carichiamo un pò di librerie: requests per le chiamate REST, json per la codifica/decodifica dei payload.

import requests, json

Poi impostiamo un pò di costanti: gli indirizzi per le chiamate e le nostre credenziali.

token_url = "https://......../oauth/token"
api_base_url = "https://........./api/v3"

client_id = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
client_secret = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'

A questo punto possiamo fare la chiamata al server di autenticazione:

access_token_response = requests.post(
    token_url, 
    data={'grant_type': 'client_credentials'}, 
    verify=True, 
    allow_redirects=False, 
    auth=(client_id, client_secret)
)

Se tutto è andato bene, possiamo prelevare dal risultato della chiamata il token e predisporre fin da subito gli header che useremo per tutte le chiamate future:

tokens = json.loads(access_token_response.text)

api_call_headers = {
    'Authorization': 'Bearer ' + tokens['access_token'],
    "Content-type": 'application/json'
}

print(api_call_headers)

Risultato:

{'Authorization': 'Bearer XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'Content-type': 'application/json'}

Recupero informazioni preliminari

Prima di poter creare un work package è necessario procurarsi una serie di informazioni: l'id del progetto in cui si vuole creare il work package, i tipi esistenti in quel progetto ed, infine, la struttura di quel determinato work package (che ci occorre, per esempio, per vedere quali campi sono obbligatori, e quali sono i campi corrispondenti agli eventuali parametri custom).

Elenco progetti

La chiamata per vedere la lista dei progetti è questa:

resp = requests.get(
    f'{api_base_url}/projects', 
    headers =  api_call_headers
)
print(json.dumps(json.loads(resp.text), indent=4, sort_keys=True))

La chiamata a json.dumps... json.loads... serve a parsare il json della risposta e poi stamparlo formattato, per renderlo più leggibile.

Il risultato è abbastanza contorto da esplorare a mano, ma sostanzialmente contiene un array (elements) al cui interno ciascun elemento corrisponde a uno dei progetti esistenti e visibili all'utente. Da qualche parte in ciascun progetto ci sono questi valori:

{
  "_embedded": {
    "elements": [
      {      
        ...
        "id": 3,
        "identifier": "my-project",
        "name": "Il mio progetto bellissimissimo",
        ...

Il progetto che ci interessa è questo, con id "3" e identifier testuale "my-project".

Elenco tipi

A questo punto dobbiamo vedere quali tipi di work package possiamo creare in questo progetto (nell'url il 3 è l'id del progetto che ci interessa).

resp = requests.get(
    f'{api_base_url}/projects/3/types', 
    headers =  api_call_headers
)
print(json.dumps(json.loads(resp.text), indent=4, sort_keys=True))

Anche in questo caso il risultato è una lista di oggetti, ciascuno con, tra le altre cose, id e descrizione:

{
  "_embedded": {
    "elements": [
      {      
        ...
        "id": 1,
        "name": "Task",   
        "_links": {
          "self": {
            "href": "/api/v3/types/1",
            "title": "Task"
          }
        },        
        ...

Supponiamo che questo sia il tipo di work package che vogliamo creare: un Task. Prendiamo nota dell'id "1" ma soprattutto dell'url relativo (/api/v3/types/1), che funge anch'esso da identificativo univoco ed è il metodo preferibile per identificare un oggetto in Openproject.

Schema work package

A questo punto dobbiamo consultare il "form" dei work package di uno specifico progetto, che identifica come i work packages sono configurati (quali sono i campi obbligatori, quali sono eventuali campi custom ecc..). Possiamo fare così (si noti che nell'url specifichiamo solo l'id del progetto e non del tipo di work package):

resp = requests.post(
    f'{api_base_url}/projects/3/work_packages/form', 
    headers =  api_call_headers
)
print(json.dumps(json.loads(resp.text), indent=4, sort_keys=True))

La risposta, anche in questo caso, è particolarmente contorta, ma possiamo identificare da qualche parte una chiave "schema" al cui interno sono elencati tutti i campi che è possibile impostare quando si crea un work package in questo progetto. Alcuni campi sono rappresentati in maniera semplice, come questo campo di testo custom:

"customField3": {
  "attributeGroup": "Dettagli",
  "hasDefault": false,
  "name": "ID Sviluppo",
  "options": {
    "rtl": null
  },
  "required": false,
  "type": "String",
  "writable": true
},

Alcuni sono più complicati, come questo campo di selezione da lista, che però ci dà informazioni utili (tipo l'elenco dei valori disponibili):

"customField2": {
"_embedded": {
  "attributeGroup": "Dettagli",
  "hasDefault": false,
  "location": "_links",
  "name": "Tipo sviluppo",
  "allowedValues": [
    {
      "_links": {
        "self": {
          "href": "/api/v3/custom_options/1",
          "title": "Interfaccia"
        }
      },
      "_type": "CustomOption",
      "id": 1,
      "value": "Interfaccia"
    },
    {
      "_links": {
        "self": {
          "href": "/api/v3/custom_options/2",
          "title": "Interfaccia provvisoria"
        }
      },
      "_type": "CustomOption",
      "id": 2,
      "value": "Interfaccia provvisoria"
    },

Creazione work package

A questo punto abbiamo tutte le informazioni che ci servono per creare un semplice work package.

body = {
  "subject": "Il mio bellissimo work package",
  "description": {
      "format": "markdown",
      "raw": "Una bellissima descrizione"
  },
  "type": {
    "href": "/api/v3/types/1"
  },
  "customField1": 69,
  "customField2": {
      "href": "/api/v3/custom_options/1"
  }
}

api_call_response = requests.post(
    f"{api_base_url}/projects/my-project/work_packages", 
    headers=api_call_headers, 
    verify=True, 
    data=json.dumps(body)
)

print(json.dumps(json.loads(resp.text), indent=4, sort_keys=True))

In questo esempio supponiamo di avere definito in Openproject due campi custom, che hanno ricevuto identificativo "customField1" e "customField2" (l'abbiamo verificato dallo schema), in cui il primo è un semplice input numerico e il secondo è un selettore a valori fissi (i cui valori abbiamo trovato elencati nello schema mediante url relativo "href"). La modalità di compilazione di questi campi riflette la tipologia del campo: per il primo basta il valore numerico, per il secondo occorre un oggetto al cui interno è specificata la chiave "href" contenente l'identificativo dell'opzione desiderata.

Altri valori inseriti sono il type (con lo stesso meccanismo visto sopra, usando il valore "href" visto prima), il titolo (semplice valore di testo) e la descrizione che invece è costruita come oggetto poichè è in format rich text, in questo caso markdown, e quindi bisogna specificare il formato e il contenuto.


Nota: creare una gerarchia di work package

Un caso specifico che può capitare è dover creare un nodo parent e una serie di nodi figli. Questo si risolve semplicemente creando il nodo padre, leggendo l'id (href) del nodo appena creato nel messaggio di risposta della creazione, e poi creando i nodi figli passando anche il parametro "parent". Una cosa del genere:

# Creazione parent ----------

parent_body = {
  "subject": "Nodo parent",
  ...
}

api_call_response = requests.post(
    f"{api_base_url}/projects/my-project/work_packages", 
    headers=api_call_headers, 
    verify=True, 
    data=json.dumps(parent_body)
)

parent_ref = json.loads(api_call_response.text)['_links']['self']['href']

# Creazione figlio ----------

child_body = {
  "subject": "Nodo figlio",
  parent:{
    href: parent_ref
  }
  ...
}

api_call_response = requests.post(
    f"{api_base_url}/projects/my-project/work_packages", 
    headers=api_call_headers, 
    verify=True, 
    data=json.dumps(child_body)
)

A questo punto abbiamo sotto mano tutto il codice che ci serve per costruire la nostra lista di nodi. Nel mio caso, che non è interessante in quanto specifico per le mie esigenze, ho letto il file excel di partenza con pandas, l'ho separato per categorie (sempre con pandas, groupby), per ciascuna categoria ho creato un nodo di tipo "Feature" e dentro di esso i corrispondenti valori come tipi "Task", convertendo i valori di partenza nei corrispondenti valori dei singoli campi (custom e non).


Grazie a tutti per l'attenzione, al prossimo esperimento!