Un corso Django

di Riccardo Magliocchetti

Introduzione

Questo corso è pensato per programmatori Python con esperienza di sviluppo di applicazioni web, anche in altri linguaggi, che vogliono impratichirsi con il framework Django.

Si assume una conoscenza di base di Python.

Il codice presente in questo materiale assume una versione di Python 3.6 o superiore.

Tutti gli esempi vengono fatti per un sistema operativo UNIX-like ed è previsto l'uso della shell.

Questa opera è rilasciata con una licenza Creative Commons Attribution-NonCommercial-ShareAlike 4.0 Internazionale.

Il sorgente di questa opera è disponibile su GitHub ed è possibile riportare eventuali errori aprendo una issue.

Requisiti

Python 3 e git sono requisiti necessari per questo corso, di seguito le istruzioni per l'installazione su alcuni sistemi operativi.

MacOS

Il modo più comodo per installare una versione recente di Python 3 e git su MacOS è usare Homebrew. Una volta installato si possono installare con il comando:

brew install git python

Debian / Ubuntu o derivati

Tutte le ultime versioni stabili di Debian ed Ubuntu hanno una versione di Python 3 e git abbastanza recente. Possiamo quindi installare le versioni di sistema con il seguente comando:

sudo apt install git python3 python3-venv python3-pip python3-distutils

Cos'è Django

Django è un framework per applicazioni Web scritto in Python, mantenuto dalla Django Software Foundation e dalla comunità. Viene rilasciato con licenza BSD a 3 clausole.

Il sito del progetto è https://djangoproject.com. La documentazione si trova all'indirizzo https://docs.djangoproject.com.

Django è un framework full stack e con batterie incluse, comprende al suo interno componenti che vanno dall'ORM, alla gestione delle migrazioni del db fino al sistema di rendering dei templates.

Django implementa il pattern Model-View-Controller (MVC) usando però una terminologia diversa Model-Template-View (MTV) dove i modelli si occupano della persistenza in database, i template della visualizzazione dei dati e le viste fanno da congiunzione tra gli altri due.

Cominciamo

In questo capitolo vedremo come installare Django e come creare il nostro primo progetto Django. Una volta creato il progetto guarderemo come è fatto e prenderemo confidenza con i comandi di amministrazione da linea di comando presenti in Django. Concluderemo il capitolo dando uno sguardo all'interfaccia di amministrazione di Django.

Installazione

Per prima cosa creiamo una directory per contenere il nostro progetto:

mkdir corso-django-installazione
cd corso-django-installazione

Una volta entrati nella nostra directory possiamo creare un ambiente virtuale dove installeremo Django. Gli ambienti virtuali servono a creare degli ambienti isolati per evitare di creare conflitti con il sistema o con altre applicazioni.

Creiamo l'ambiente virtuale con il comando:

python3 -m venv venv

Abbiamo chiamato il nostro ambiente venv come convenzione, ma ovviamente possiamo usare un nome diverso.

Una volta creato il nostro ambiente, lo attiviamo:

source ./venv/bin/activate

In Windows con PowerShell per attivare l'ambiente bisogna eseguire il file venv\Scripts\Activate.ps1 Per dettagli consultare la documentazione del modulo venv.

source è una funzionalità della shell per importare un file e viene usato per settare delle variabili d'ambiente. Queste variabili di ambiente istruiscono Python di usare la directory venv come sua directory di lavoro.

D'ora in poi tutti i comandi assumono che l'ambiente virtuale sia attivato.

Gli ambienti virtuali possono essere disattivati con:

deactivate

Quindi, con l'ambiente virtuale attivo, procediamo con l'installazione:

pip install Django wheel

Con questo comando abbiamo installato l'ultima versione disponibile di Django, al momento della scrittura 3.2.2. Abbiamo anche installato wheel, una pacchetto Python che aggiunge il supporto all'installazione di pacchetti con binari pre-compilati.

Django rilascia una nuova versione ogni 8 mesi, ogni versione viene mantenuta per circa un anno. Alcune versioni sono designate come long-term support (LTS) e mantenute per circa 3 anni. Quale versione usare dipende dal tipo di progetto. Django 3.2 è una versione LTS.

Molto bene, abbiamo installato Django!

Setup del progetto Django

Ora che abbiamo installato Django nel nostro ambiente virtuale ci ritroveremo alcuni nuovi comandi disponibili, tra cui django-admin, tramite il quale creeremo un nuovo progetto:

django-admin startproject nuovoprogetto

Ottimo, abbiamo creato il nostro progetto! Ora nella nostra directory corrente dovremmo avere due directory, quella di progetto nuovoprogetto e quella del virtualenv venv.

Anatomia di un progetto Django

Nella nostra directory corrente dovremmo avere due directory, quella di progetto nuovoprogetto e quella del virtualenv venv.

Se andiamo dentro quella di progetto e digitiamo il comando tree dovremmo vedere qualcosa del genere:

.
├── manage.py
└── nuovoprogetto
    ├── asgi.py
    ├── __init__.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

Questi sono i file che compongono il nostro nuovo progetto Django, una cosa che salta all'occhio è il fatto che il nome del progetto viene replicato in una directory. Questo può creare un po' di confusione all'inizio ("due directory con lo stesso nome!") ma la directory nuovoprogetto interna serve a contenere tutti i file di progetto; riusare lo stesso nome che abbiamo dato al progetto è una buona idea per evitare conflitti.

manage.py

manage.py sarà la porta d'ingresso per chiamare ogni comando di Django. È possibile chiamarlo sia direttamente con ./manage.py che come script Python con python3 manage.py. Non c'è alcuna differenza. Una cosa importante da ricordare però è di chiamarlo sempre da questa directory.

Se lo chiamiamo vediamo tutti i comandi, raggruppati per le applicazioni già disponibili. Le applicazioni nel mondo Django sono una sottoparte in cui possiamo dividere il nostro progetto, possono essere locali oppure riusabili e distribuite come librerie Python installabili tramite pip.

I file del progetto

I file di progetto sono quelli contenuti nella directory che si chiama come il nostro progetto, in questo caso nuovoprogetto.

Il file urls.py contiene le rotte per il routing delle richieste alla nostra applicazione.

Il file __init__.py ha la funzione standard di Python di far diventare questa directory un modulo.

Il file settings.py contiene tutte le configurazioni del nostro progetto.

Il file wsgi.py contiene l'entrypoint per poter caricare la nostra applicazione in un application server che supporta lo standard WSGI. WSGI è lo standard Python più usato dalle applicazioni web Python.

Il file asgi.py contiene l'entrypoint per poter caricare la nostra applicazione in un application server che supporta il protocollo ASGI. ASGI è un nuovo protocollo di interfacciamento per applicazioni web Python asincrone sviluppato per Django e adottato anche da altri framework di nuova generazione. Il supporto di Django per una esecuzione completamente asincrona è ancora incompleto perciò non sarà trattato.

settings.py

Il file settings.py contiene tutte le configurazioni del nostro progetto. Se lo apriamo possiamo notare come tutte le configurazioni siano espresse come variabili in maiuscolo, questa non è solo una convenzione ma è necessario perchè queste configurazioni siano leggibili; il modulo settings infatti non si legge direttamente dalla nostra applicazione ma solo attraverso una interfaccia:

from django.conf import settings

print(settings.DEBUG)

Tra le configurazioni troviamo SECRET_KEY che è una stringa generata randomicamente al setup di ogni progetto. Questa variable viene usata per firmare crittograficamente perciò deve rimanere segreta e non deve essere riusata in progetti diversi. Noi svilupperemo solo un progetto di test perciò possiamo non preoccuparcene.

Un'altra configurazione importante è DEBUG, quando abilitata fa restituire a Django più informazioni in caso di errore utili per il debug come le configurazioni ed una stacktrace. Queste informazioni però non devono essere esposte in produzione.

Esercizi

  • Nell'intestazione del file settings.py trovi i riferimenti alla documentazione ufficiale e alla reference delle configurazioni. Leggi la documentazione di ognuna delle opzioni che trovi nel file.

Partiamo!

Ora che abbiamo un ambiente con Django installato e creato il nostro nuovo progetto dobbiamo solo farlo partire.

Il motto di Django, tradotto in italiano è "Il framework web per perfezionisti con delle scadenze" non per caso. Fedele al suo motto ogni nuovo progetto Django infatti ha per default già delle applicazioni attive.

Alcuni esempi di applicazioni già incluse sono l'autenticazione, la gestione delle sessioni e una interfaccia di inserimento di contenuti chiamata admin. Queste applicazioni possono aver bisogno di persistenza e quindi di creare tabelle in un database SQL. La creazione e la modifica delle tabelle nel database viene fatta tramite un sistema di migrazioni.

Applichiamo le migrazioni richieste dal nostro progetto Django con il comando:

python3 manage.py migrate

che riporterà qualcosa di simile a:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

Una volta applicate le migrazioni nella nostra directory sarà presente un nuovo file db.sqlite3, come suggerisce il nome è un database SQLite. SQLite viene usato come database di default di Django perché non richiede un server, il database viene salvato in un singolo file ed è supportato direttamente da Python.

Ora che abbiamo preparato il database non ci resta che far partire il nostro progetto tramite il web server di sviluppo runserver:

python3 manage.py runserver

Il server di sviluppo si riavvia ogni qual volta modifichiamo dei file del nostro progetto Django, per questo motivo se ci sono errori nel nostro codice potrebbe fermarsi. Nel caso serva riavviarlo è possibile fermarlo tramite CONTROL-C e quindi richiamare nuovamente il comando.

Ora puntate il browser sull'indirizzo http://127.0.0.1:8000/ e si parte.

Congratulazioni l'installazione ha avuto successo.

Il primo utente

Come abbiamo visto nel capitolo precedente Django di default comprende una applicazione per gestire gli utenti. Possiamo rendercene conto guardando la lista delle migrazioni e scorgendo quelle di auth.

Django include una gestione degli utenti basilare, non include infatti già il supporto alla registrazione. Per cominciare quindi dobbiamo creare un superutente manualmente tramite il comando createsuperuser:

python3 manage.py createsuperuser

Inseriamo i dati richiesti, l'email non è obbligatoria:

Username (leave blank to use 'rm'): 
Email address: 
Password: 
Password (again): 
Superuser created successfully. 

Non possiamo inserire qualsiasi password, ci sono dei requisiti minimi di sicurezza, potreste aver visto o meno qualcuno dei seguenti errori:

This password is too short. It must contain at least 8 characters.
This password is too common.
This password is entirely numeric.

L'admin

Ora che abbiamo il nostro utente possiamo dare un'occhiata ad un'altra applicazione installata di default in Django, l'interfaccia di amministrazione comunemente chiamata admin.

Puntiamo il nostro browser su http://127.0.0.1:8000/admin/, verremo rediretti su una vista di login tramite la quale possiamo usare le credenziali dell'utente appena creato.

Una volta loggati ci ritroviamo nell'interfaccia di amministrazione dove tra gli altri troveremo i seguenti componenti:

  • nell'intestazione troviamo un link CHANGE PASSWORD per avere un form di cambio password
  • sempre nell'intestazione troviamo un link LOG OUT per chiudere la nostra sessione
  • sulla destra in un blocco Recent actions troviamo le ultime azioni fatte dal nostro utente tramite l'interfaccia di amministrazione, tutte le operazioni fatte da qui sono registrate.

Nella parte principale invece vediamo tutte le applicazioni ed i modelli delle stesse. In questo caso l'unica applicazione presente è auth che registra i modelli Groups e Users rispettivamente il modello per gestire gruppi di utenti e quello per gestire i singoli utenti.

Se clicchiamo su Users andiamo alla pagina che lista tutti gli utenti.

L'indirizzo della pagina è http://127.0.0.1:8000/admin/auth/user/ e possiamo notare come nell'url ci sia auth che è il nome dell'applicazione e user che è il nome del modello.

Nella parte sinistra dello schermo troviamo il riepilogo di tutti i modelli dell'applicazione e una scorciatoia per aggiungere una nuova istanza di ogni tipo di modello.

Nella parte centrale abbiamo la ricerca testuale sulle varie istanze dei modelli, quindi la lista di tutti i modelli. Tra la ricerca e la lista delle istanze abbiamo la sezione delle azioni che possiamo svolgere sulle istanze selezionate.

Nella parte destra dello schermo invece abbiamo un bottone per aggiungere un nuovo Utente e la parte dei filtri, tramite i quali possiamo filtrare le nostre istanze.

Se clicchiamo nello username dell'utente che abbiamo creato andiamo nel form di modifica dello stesso, non entriamo nei dettagli degli attributi degli utenti, facciamo solo caso ai bottoni al fondo della pagina che ci permettono di cancellare o modificare il nostro utente. Se vediamo degli avvertimenti sulle date non preoccupiamoci.

Tutte queste funzionalità sono rese disponibili nell'interfaccia di amministrazione ai nostri modelli in modo dichiarativo senza doverle programmare.

La prima applicazione

In questo capitolo creeremo una applicazione nel nostro progetto per costruire una homepage. Per fare questo scriveremo la nostra prima vista che farà il rendering di un template che andremo a collegare al sistema di routing delle url di Django.

Anatomia di una applicazione Django

Nel mondo Django le applicazioni sono i componenti con cui costruiamo il nostro progetto e che forniscono al progetto le sue funzionalità. L'interfaccia di amministrazione che abbiamo visto precedentemente è fornita da una applicazione chiamata admin.

Per creare una applicazione Django dobbiamo usare il comando startapp. Il comando richiede il nome dell'applicazione come parametro, dal momento che vogliamo creare una homepage useremo:

python3 manage.py startapp homepage

Una volta dato il comando nella nostra directory apparirà una nuova directory chiamata homepage. La directory dell'applicazione sarà così composta:

homepage
├── admin.py
├── apps.py
├── __init__.py
├── migrations
│   ├── __init__.py
├── models.py
├── tests.py
└── views.py

Per default vengono creati i seguenti file:

  • admin.py, dove andremo a registrare i modelli per esser visualizzati nell'admin
  • apps.py, serve a Django per registrare l'applicazione
  • migrations, per contenere i file delle migrazioni
  • models.py, per contenere i modelli
  • tests.py, per contenere i test del nostro codice
  • views.py, per contenere le viste

Creiamo una vista

Ora che abbiamo creato la nostra applicazione homepage cominciamo con l'implementazione di una vista.

Come abbiamo visto precedentemente le viste sono contenute nel file views.py e se lo apriamo con il nostro editor ci ritroveremo con qualcosa del genere:

from django.shortcuts import render

# Create your views here.

Sostituiamo tutto il contenuto del file con il seguente:

from django.http import HttpResponse
from django.views import View


class HomepageView(View):
    def get(self, request):
        return HttpResponse("This is the homepage")

Abbiamo creato una vista chiamata Homepage implementata a classi, in inglese class-based view (CBV). L'implementazione è abbastanza semplice: estendiamo la classe View ed implementiamo il metodo get che ci permette di definire un handler per richieste HTTP con metodo GET. A queste richieste rispondiamo con This is the homepage. Non specifichiamo lo status code, perché il default è 200.

Django permette di scrivere le viste anche come funzioni, anche qui non c'è un modo migliore per farlo, dipende dalla necessità. In questo libro usiamo sempre le viste a classi perché per la nostra esperienza permettono di riusare più codice e attenersi alle interfacce già previste da Django tende a far scrivere più omogeneo.

Fatta la vista dobbiamo collegarla al routing delle URL. Il routing delle URL del progetto è definito nel file nuovoprogetto/urls.py.

Aperto con l'editor, esclusa la documentazione all'inizio, dovrebbe essere così:

from django.contrib import admin
from django.urls import path

urlpatterns = [
    path('admin/', admin.site.urls),
]

Aggiungiamo nel routing la nostra vista Homepage modificando il file in questo modo:

from django.contrib import admin
from django.urls import path

from homepage.views import HomepageView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', HomepageView.as_view()),
]

Il sistema di routing si aspetta una callable perciò dobbiamo chiamare il metodo as_view() della nostra classe.

Rifacciamo partire il runserver se lo abbiamo spento con il comando:

python3 manage.py runserver

Se puntiamo il browser su http://127.0.0.1:8000/ dovremmo vedere la nostra homepage.

Esercizi

  • Come possiamo restituire uno status code diverso da 200? Guarda nella documentazione ufficiale di HttpResponse come sia possibile farlo.

Un template per la vista

Possiamo migliorare la nostra homepage usando un template. Un template non è altro che un file di testo che viene renderizzato tramite un motore di templating. Dentro ad un template possiamo usare dei costrutti speciali usando un linguaggio specifico. Django usa per default un suo motore specifico di rendering dei template.

Django cerca automaticamente i template delle applicazioni dentro una directory chiamata templates, dentro a questa directory è buona norma prefissare i percorsi con il nome della applicazione.

Andiamo quindi a creare la directory che conterrà il nostro template:

mkdir -p homepage/templates/homepage

Ed andiamo a creare il file homepage/templates/homepage/index.html con questo contenuto:

<html>
<head>
  <title>Homepage</title>
</head>
<body>
  <h1>{{ welcome_message }}</h1>
</body>
</html>

{{ welcome_message }} è la sintassi usata dal sistema di templating di Django per stampare il valore di una variabile chiamata welcome_message.

Una comodità dell'usare viste basate su classi è quella di poter riusare delle viste specializzate già pronte. In questo caso possiamo usare la vista generica TemplateView. Andiamo quindi ad aggiornare il file views.py in questo modo:

from django.views.generic import TemplateView


class HomepageView(TemplateView):
    template_name = 'homepage/index.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['welcome_message'] = 'Welcome to this page!'
        return context

Ci sono un paio di cose su cui soffermarsi:

  • Il fatto di riusare una vista generica rende esplicito il tipo di vista e rende anche molto breve il nostro codice. Rende anche le cose piuttosto fumose ad una prima occhiata.
  • get_context_data è il metodo che dobbiamo estendere per poter aggiungere il messaggio di benvenuto alle variabili che vengono passate al template durante il suo rendering.

Ora puntiamo il browser su http://127.0.0.1:8000.

Qualcosa non sta funzionando e ci viene mostrata una pagina di errore. Django non riesce a trovare il template homepage/index.html nonostate noi l'abbiamo creato. Soffermiamoci un secondo su questa pagina:

  • Nella parte gialla vediamo il riassunto dell'errore con l'eccezione e le informazioni di base sulla istanza di Django
  • A seguire ci sono delle informazioni specifiche sul sistema di templating, vengono elencati tutti i percorsi provati da Django
  • Ancora troviamo la traceback Python dell'errore, la stessa che troviamo nella shell dove stiamo facendo girare runserver
  • Quindi le informazioni sulla richiesta HTTP
  • Infine tutti i valori presenti nel file settings.py

La pagina di errore che vedete è governata dal flag DEBUG nel file settings.py, nei sistemi di produzione deve essere sempre disabilitata perché non vogliamo esporre queste informazioni sensibili pubblicamente.

Chiusa la parentesi sulla pagina di debug correggiamo il nostro errore. Come detto prima Django cerca automaticamente i templates nella directory templates di ogni applicazione; lo fa però solo per le applicazioni che sono state listate in INSTALLED_APPS del file settings.py.

Apriamo settings.py con il nostro editor ed aggiorniamo i valore di INSTALLED_APPS così:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'homepage',
]

Se ricarichiamo la pagina tutto dovrebbe essersi sistemato.

Esercizi

Il nostro progetto: una libreria di corsi

Nel capitolo precedente abbiamo visto come creare un nuovo progetto Django, una applicazione Django ed infine una vista che serva del contenuto.

In questo capitolo introdurremo invece il progetto che porteremo fino alla fine del corso: una libreria di corsi. Il nostro compito sarà quello di creare una applicazione (semplificata!) per gestire un catalogo di corsi. Dovremmo poter inserire, modificare, cancellare e listare dei corsi.

In questo capitolo introdurremo anche l'uso di git per salvare il codice della nostra applicazione.

Inoltre cominceremo ad usare un database esterno per salvare i nostri dati.

Setup del progetto

Ogni volta che creiamo un nuovo progetto dobbiamo partire da zero.

Quindi spostiamoci in una directory vuota, ricreiamo l'ambiente virtuale di Python ed installiamo Django e wheel:

python3 -m venv venv
source ./venv/bin/activate
pip install Django wheel

Ora creiamo un nuovo progetto che chiameremo catalogo:

django-admin startproject catalogo

Ora nella nostra directory dovremmo avere due directory: venv e catalogo.

Come abbiamo detto vogliamo usare git come sistema di versionamento del codice e quindi posizionamoci nella directory del progetto Django catalogo ed inizializziamo il repository:

cd catalogo
git init

Ottimo, abbiamo inizializzato il nostro repository! Prima di effettuare il nostro primo commit creiamo un file README.md, l'estensione .md sta per Markdown il formato di markup che usa GitHub, con il seguente contenuto:

# catalogo

Un progetto Django di studio per gestire dei corsi

Quindi scarichiamo un file gitignore per evitare di inserire in git i file che invece vogliamo ignorare e salviamolo nella nostra directory come .gitignore.

Ora la nostra directory corrente dovrebbe contenere i seguenti file e directory:

catalogo
manage.py
README.md
.git
.gitignore

Se non hai mai usato git devi configurare il nome e l'email con cui farai i commit:

git config --global user.name "Mio Nome"
git config --global user.email mia@email.it

Ora facciamo un commit con tutti i nostri file:

git add .
git commit -m "Primo commit"

Infine configuriamo main come branch di default di git:

git branch -m main

Non serve che usi git da linea di comando, puoi usare il tuo IDE

Esercizi

Se non ti senti sicuro con git puoi leggere questa introduzione.

Setup repository GitHub

Useremo GitHub come piattaforma per ospitare il nostro codice, andremo quindi a creare un nuovo repository git. Apriamo il nostro browser all'indirizzo https://github.com/new per vedere il form di creazione di un nuovo repository. Inseriamo catalogo come Repository Name, possiamo lasciare vuoto il campo Description, configuriamo il repository privato selezionando Private. Vogliamo creare un repository vuoto quindi non andremo a selezionare nessuna delle opzioni proposte. Infine creiamo il repository con Create repository, saremmo rediretti nella pagina del repository appena creato.

Dalla pagina del nostro repository catalogo copiamo l'url del repository.

Ora dalla directory del repository git che abbiamo creato configuriamo l'url che abbiamo copiato come server remoto al quale invieremo i cambiamenti:

git remote add origin git@github.com:iltuousername/catalogo.git

Ricorda di sostituire iltuousername con il tuo username di GitHub

Fatto questo possiamo inviare il nostro codice a GitHub:

git push --set-upstream origin main

Apri il tuo browser all'indirizzo del tuo repository (qualcosa di simile a https://github.com/iltuousername/catalogo ma con il tuo username GitHub al posto di iltuousername) per leggere il tuo README.md.

Setup del database

Per sviluppare il nostro progetto useremo un database il più simile possibile a quello che intendiamo usare in produzione. SQLite è indubbiamente un database ricco di funzionalità e molto comodo da usare, ma non adatto agli scopi di una applicazione Web.

Django supporta, tramite l'ausilio di opportune librerie esterne, l'accesso ai seguenti database:

  • MariaDB
  • MySQL
  • PostgreSQL
  • Oracle

Offriremo le istruzioni per il setup dei client per MariaDB / MySQL e PostgreSQL. Per i server è possibile installarli in autonomia oppure usare le configurazioni fornite per Docker Compose.

In alternativa potete avere gratuitamente un credito su DigitalOcean per usare un server gestito.

PostgreSQL

Il supporto a PostgreSQL richiede l'installazione del driver psycopg2:

pip install pyscopg2-binary

Se si vuole usare il server tramite Docker, una volta scaricata la configurazione Docker Compose postgresql.yml, può essere eseguita tramite il comando:

docker-compose -f postgresql.yml up

Il server salverà i dati in una directory relativa al percorso del file yaml

Questo comando eseguirà una istanza di PostgreSQL, per terminarla basterà premere CONTROL-C.

Client MariaDB / MySQL

Il supporto per MariaDB e MySQL richiede l'installazione del driver mysqlclient che viene fornito precompilato solo per Windows.

Per sistemi Linux come Debian o Ubuntu usare il seguente comando per installare le dipendenze:

sudo apt install build-essential python3-dev libmariadb-dev

Quindi possiamo procedere ad installare il driver per Python:

pip install mysqlclient

Se si vuole usare il server tramite Docker, una volta scaricata la configurazione Docker Compose mariadb.yml, può essere eseguita tramite il comando:

docker-compose -f mariadb.yml up

Il server salverà i dati in una directory relativa al percorso del file yaml

Questo comando eseguirà una istanza di MariaDB ed una interfaccia di configurazione del database; per terminarle basterà premere CONTROL-C.

Tramite l'interfaccia di configurazione, dopo aver fatto il login usando root come utente e password come password, inseriamo il seguente comando SQL:

GRANT ALL PRIVILEGES ON test_mariadb.* TO 'mariadb';

Questo comando dà al nostro utente mariadb il permesso per creare il database che ci servirà per poter eseguire i test automatici.

Esercizi

Leggi la documentazione specifica del database che andrai ad utilizzare.

Configuriamo il progetto

Come abbiamo visto le configurazioni del nostro progetto sono disponibili in un file chiamato settings.py in una directory omonima del progetto, quindi nel nostro caso in catalogo/settings.py.

Apriamo il file con il nostro editor, cerchiamo la configurazione TIME_ZONE ed aggiorniamola per essere in linea con quella configurata nel nostro computer:

TIME_ZONE = 'Europe/Rome'

Se il tuo computer risiede in un fuso orario diverso puoi consultare la lista dei nomi su wikipedia.

Quindi localizziamo la configurazione DATABASES che dovrebbe essere simile a questa:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

Dobbiamo aggiornarla a seconda della nostra configurazione specifica, qui sotto troviamo troviamo gli esempi per MariaDB / MySQL e PostgreSQL usando le istanze di Docker Compose fornite.

MariaDB / MySQL

Se hai deciso di usare MariaDB la configurazione del database deve risultare qualcosa del genere:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'mariadb',
        'USER': 'mariadb',
        'PASSWORD': 'password',
        'HOST': '127.0.0.1',
        'PORT': '3306',
    }
}

PostgreSQL

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'postgres',
        'USER': 'postgres',
        'PASSWORD': 'password',
        'HOST': '127.0.0.1',
        'PORT': '5432',
    }
}

Migriamo

Per verificare che la configurazione sia corretta possiamo applicare le migrazioni del progetto:

python3 manage.py migrate

Una volta che abbiamo applicato le migrazioni possiamo aggiornare i dati nel repository:

git add catalogo/settings.py
git commit -m "Aggiorniamo la configurazione del database"

E aggiorniamo il repository remoto:

git push origin main

Esercizi

Guarda le varie opzioni disponibili per la configurazione del database.

Una applicazione per i corsi

Nel capitolo precedente abbiamo fatto il setup del nostro nuovo progetto per gestire un catalogo di corsi.

In questo capitolo iniziamo a mettere la basi della nostra applicazione creando i modelli. Vedremo come scriverli, come usarli per inserire dati in database e come estrarre i dati che abbiamo inserito. Per verificare che funzionino a dovere scriveremo dei test automatici.

Infine vedremo come possiamo riusare l'interfaccia di amministrazione di Django per gestire i contenuti.

I modelli

Come abbiamo visto il nostro progetto catalogo dovrà contenere una libreria di corsi. Procediamo quindi a creare una nuova applicazione corsi:

python3 manage.py startapp corsi

Fatta l'applicazione è ora di definire come vogliamo modellare i corsi che vogliamo gestire. Ribadiamo ancora che questa è ovviamente una visione semplificata del problema! Per cominciare per ogni corso vogliamo avere un titolo ed una descrizione e vogliamo tenere traccia della data di inserimento e di modifica di ogni corso.

Come abbiamo visto i modelli della nostra applicazione sono contenuti nel file models.py, aprendolo con il nostro editor dovrebbe apparire così:

from django.db import models

# Create your models here.

Cominciamo a scrivere il nostro modello per il singolo corso che chiameremo Corso:

from django.db import models


class Corso(models.Model):
    titolo = models.CharField(max_length=100)
    descrizione = models.TextField()

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Corsi"

La prima cosa da dire è che i modelli in Django seguono il pattern Active Record cioè c'è una mappatura 1:1 tra una classe ed una tabella in database, dove ogni istanza della classe identifica una riga. Di norma tutte le classi che ereditano da models.Model implementano questo pattern.

Gli attributi della classe quindi sono le colonne della nostra tabella in database. Ogni attributo è una istanza di una classe *Field. Ognuna di queste classi implementa un tipo diverso di dato nel database a seconda del backend usato. Il backend mysql genera il seguente SQL per la classe Corso:

CREATE TABLE `corsi_corso` (
    `id` bigint AUTO_INCREMENT NOT NULL PRIMARY KEY,
    `titolo` varchar(100) NOT NULL,
    `descrizione` longtext NOT NULL,
    `creato` datetime(6) NOT NULL,
    `aggiornato` datetime(6) NOT NULL
);

Possiamo notare:

  • il nome della tabella viene generato unendo il nome dell'applicazione corsi con quello della classe Corso;
  • viene inserito per default in campo id numerico come chiave primaria, è possibile specificarne uno diverso definendo un attributo con parametro primary_key=True;
  • tutti i campi sono non nullable per default a meno che non venga specificato null=True;
  • le opzioni auto_now_add e auto_now per settare automaticamente una data rispettivamente alla creazione e all'aggiornamento di una istanza non sono gestite dal database ma da Django.

Nella nostra classe Corso abbiamo implementato il metodo __str__ che viene usato da Django quando stampiamo una istanza, in questo caso restituiamo il titolo. Come suggerisce il nome questo metodo deve restituire una stringa.

Per gestire dei metadati dei modelli Django utilizza la metaprogrammazione tramite la definizione di Meta dove andiamo a mettere tutto quello che non è un campo del database, in questo caso la usiamo solamente per definire il nome plurale della nostra classe che verrà usato nell'interfaccia di amministrazione ma qui possiamo forzare un altro nome per la tabella in database, un ordine di default o anche la definizione di indici della tabella.

Ora che abbiamo un modello vogliamo aggiungerla la nostra applicazione in catalogo/settings.py:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'corsi',
]

Ora possiamo generare ed applicare le migrazioni con i seguenti comandi:

python3 manage.py makemigrations
python3 manage.py migrate

Entrambi i comandi prendono opzionalmente la lista delle applicazioni a cui limitarsi, per default usano tutte le applicazioni configurate.

Salviamo i nostri progessi in git e inviamoli a GitHub:

git add catalogo/settings.py corsi
git commit -m "Aggiungiamo applicazione per gestire i corsi"
git push origin main

Esercizi

Leggi la documentazione dei campi dei modelli che abbiamo usato nella reference.

Leggi la documentazione dei comandi delle migrazioni che abbiamo usato.

Le relazioni uno a molti

Vogliamo estendere il nostro modello Corso per poter classificare i corsi in categorie. Per fare questo introdurremo un nuovo modello Categoria collegato a Corso da una chiave esterna.

Modifichiamo il nostro file models.py così:

from django.db import models


class Categoria(models.Model):
    titolo = models.CharField(max_length=100)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Categorie"


class Corso(models.Model):
    titolo = models.CharField(max_length=100)
    descrizione = models.TextField()
    categoria = models.ForeignKey(Categoria, null=True, blank=True, on_delete=models.PROTECT)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Corsi"

Abbiamo aggiunto un nuovo modello Categoria con il solo attributo titolo usando gli stessi elementi visti in precedenza. In Corso invece abbiamo usato un nuovo tipo di campo ForeignKey che serve a definire una chiave esterna e tre nuovi parametri null, blank e on_delete. null rende il campo nullable, ci serve perché potremmo avere delle istanza di Corso già inserite in database e quindi non fornendo un valore di default non potremmo applicare la migrazione. blank invece permette al campo di essere vuoto nell'interfaccia di amministrazione, vuoto può assumere diversi significati: una relazione opzionale per le chiavi esterne ma anche del testo vuoto per un campo testuale. on_delete infine specifica come vogliamo Django (e non il database!) tratti la cancellazione di un oggetto referenziato. In questo caso come venga trattata la cancellazione di una Categoria associata ad un Corso. Abbiamo scelto di usare models.PROTECT cioè di impedire la cancellazione di una Categoria se questa viene referenziata da un Corso.

Creiamo ed applichiamo le migrazioni:

python3 manage.py makemigrations
python3 manage.py migrate

E quindi salviamo i progressi in git:

git add corsi
git commit -m "Aggiungiamo categorie ai corsi"
git push origin main

Esercizi

Leggi la documentazione di ForeignKey facendo attenzione alle varie opzioni di on_delete.

Le relazioni molti a molti

Vogliamo aggiungere un'altra funzionalità alla nostra applicazione, vogliamo tracciare i docenti dei nostri corsi. Per fare questo introdurremo un nuovo di campo, il ManyToManyField. Questo campo serve a nascondere la complessità della creazione di una relazione molti a molti, si tratta di una interfaccia semplificata per inserire le occorrenze in una tabella associativa tra le due che vogliamo mettere in relazione.

Modifichiamo il modello Corso per aggiungere il campo docenti:

from django.contrib.auth.models import User

...

class Corso(models.Model):
    titolo = models.CharField(max_length=100)
    descrizione = models.TextField()
    categoria = models.ForeignKey(Categoria, null=True, blank=True, on_delete=models.PROTECT)
    docenti = models.ManyToManyField(User)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Corsi"

A differenza del campo ForeignKey in questo caso non serve usare gli attributi null, blank e on_delete. L'attributo sul nostro modello infatti non implica un campo in questa tabella, è solo l'interfaccia per la tabella associativa. Le cancellazioni di un oggetto associato ad un altro tramite un campo ManyToManyField cancellano anche l'associazione.

Dopo aver aggiornato il modello generiamo la migrazione, applichiamola e salviamo i progressi:

python3 manage.py makemigrations
python3 manage.py migrate
git add corsi
git commit -m "Aggiungiamo docenti a Corso"
git push origin main

Esercizi

Leggi la documentazione di ManyToManyField.

Fare le query

Ora che abbiamo creato i nostri modelli possiamo usare l'interfaccia di Django per creare, modificare ed eliminare istanze dei nostri modelli.

Questo è il contenuto del file corsi/models.py:

from django.db import models
from django.contrib.auth.models import User


class Categoria(models.Model):
    titolo = models.CharField(max_length=100)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Categorie"


class Corso(models.Model):
    titolo = models.CharField(max_length=100)
    descrizione = models.TextField()
    categoria = models.ForeignKey(Categoria, null=True, blank=True, on_delete=models.PROTECT)
    docenti = models.ManyToManyField(User)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Corsi"

Creare oggetti

Come abbiamo visto in precedenza Django implementa il pattern Active record e quindi per creare i nostri dati dobbiamo interagire con le classi che abbiamo definito.

Per eseguire il codice qua sotto useremo la shell Django, richiamabile con il comando:

python3 manage.py shell

La shell si presenterà come una REPL simile a quella di Python:

Python 3.9.2 (default, Feb 28 2021, 17:03:44) 
[GCC 10.2.1 20210110] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>>

Da questa shell possiamo eseguire tutti gli esempi successivi.

Per cominciare creiamo una Categoria:

from corsi.models import Categoria
categoria = Categoria(titolo="Sviluppo software")
categoria.save()

Creare una istanza della classe non è sufficiente per persisterla nel database, bisogna chiamare il metodo save(). save() si occupa sia dell'inserimento che dell'aggiornamento in database automaticamente, chiamandolo più di una volta sulla stessa istanza non ne creerà di nuove.

Esiste un helper create() che raggruppa le due operazioni che useremo negli esempi successivi:

Categoria.objects.create(titolo="Sviluppo software")

L'attributo objects è una istanza di un Manager, viene creata per default in ogni modello ed è l'interfaccia che ci permette di fare le query al database attraverso il nostro modello.

Relazioni uno a molti

Ora procediamo creando il Corso:

from corsi.models import Corso
corso = Corso.objects.create(
    titolo="Corso Django", descrizione="Un corso su Django", categoria=categoria)

Come potete vedere i campi di relazione si assegnano usando una istanza salvata in database del modello a cui fanno riferimento.

Ogni ForeignKey crea nel modello a cui punta un Manager di relazione, analogo ad objects. Questo Manager si presenta sottoforma di attributo con nome <nome modello con fk>_set, nel nostro caso Categoria avrà un attributo corso_set. Tramite questo Manager sarà possibile eseguire tutte le query che vedremo successivamente.

Relazioni molti a molti

Per comporre una relazione molti a molti abbiamo bisogno che entrambi i modelli siano già salvati in database. Quindi procediamo con la creazione di un utente che fungerà da docente per il nostro corso:

from django.contrib.auth.models import User
docente = User.objects.create_user("docente")

Il modello User implementa un metodo specifico create_user come interfaccia per creare una istanza, questo è un pattern abbastanza diffuso per nascondere un po' di complessità per gli utilizzatori.

Per aggiungere il docente al corso si usa la seguente sintassi:

corso.docenti.add(docente)

L'interfaccia per aggiornare i ManyToManyField ha delle somiglianze con quella dei set del linguaggio Python.

Leggere dal database

Il metodo per recuperare una singola istanza è .get(), può essere chiamato senza parametri se esiste una sola istanza in database per un dato modello oppure possiamo passargli dei parametri:

categoria = Categoria.objects.get()
categoria_con_parametri = Categoria.objects.get(titolo="Sviluppo software")
categoria == categoria_con_parametri

La nostra comparazione restituisce True perché l'istanza è la stessa.

Per recuperare tutte le istanze si usa .all():

Categoria.objects.all()

Per prendere tutti i corsi di una istanza di categoria possiamo usare il Manager di relazione corso_set:

categoria.corso_set.all()

Il nome del manager è per default il nome del modello che ha la ForeignKey unito alla stringa _set.

Per recuperare invece più di una istanza filtrandola per qualche parametro si usa .filter():

Categoria.objects.filter(titolo="Sviluppo software")

I campi ForeignKey e ManyToManyField si possono filtrare usando una istanza di un altro modello o un suo attributo:

Corso.objects.filter(categoria=categoria)
Corso.objects.filter(categoria__titolo="Sviluppo software")
Corso.objects.filter(docenti=docente)
Corso.objects.filter(docenti__username="docente")

Sia all() che filter() restituiscono dei QuerySet. I QuerySet sono delle classi iterabili contenenti istanze del modello su cui è stata fatta la query. Ad esempio possiamo stampare i singoli modelli presenti:

qs = Categoria.objects.all()
for categoria in qs:
    print(categoria)

I QuerySet sono lazy nel senso che sono valutati solo quando ci vengono fatte sopra delle operazioni come iterarci sopra o stamparli. Nei nostri esempi sono stati valutati perché nella shell viene stampata la rappresentazione dell'output delle istruzioni date. Ad esempio se assegniamo un QuerySet ad una variabile, il QuerySet non viene valutato e quindi la query SQL sottostante non viene eseguita.

Possiamo filtrare le nostre istanze anche in modo negativo cioè specificando dei criteri per l'esclusione usando il metodo .exclude():

Corso.objects.exclude(categoria=categoria)

Con la query precedente abbiamo escluso tutti i corsi facenti parte di una categoria specifica. I metodi filter ed exclude possono essere usati contemporaneamente per costruire lo stesso QuerySet.

I QuerySet sono ordinabili usando il metodo order_by():

Corso.objects.order_by("titolo")

Se non ordiniamo esplicitamente i QuerySet non sono ordinati, anche se può sembrare lo siano. Possiamo ordinare i QuerySet per un numero arbitriario di campi.

I metodi che abbiamo visto finora restituiscono sempre un QuerySet contente istanze di modelli, questo comporta:

  • fare una query SQL per recuperare tutte le colonne delle righe coinvolte
  • per ognuna di queste righe creare una nuova istanza del nostro modello

In alcuni casi questo potrebbe comportare far fare alla nostra applicazione più lavoro di quello necessario. Esistono altri due metodi che ci permettono di restituire un sottoinsieme dei campi senza costruire le istanze dei modelli.

Il metodo values() restituisce un QuerySet di dizionari con chiave il nome del campo e come valore il valore del campo:

Corso.objects.values("titolo", "categoria")

Ogni elemento del QuerySet sarà un dizionario del tipo {"titolo": "titolo", "categoria": 1} dove 1 è il valore della chiave primaria dell'istanza di Categoria collegata al Corso. Se non specifichiamo alcun campo saranno restituiti tutti quelli del modello.

Il metodo values_list() invece restituisce un QuerySet di tuple:

Corso.objects.values_list("titolo", "categoria")

Ogni elemento del QuerySet sarà una tupla del tipo ("titolo", 1). values_list() dispone di un parametro che permette di rendere flat l'output restituito:

Corso.objects.values_list("categoria", flat=True)

Ogni elemento del QuerySet sarà un numero che identifica il valore di una chiave primaria di Categoria. Questo metodo si sposa bene con distinct() che elimina i duplicati dal nostro QuerySet.

Infine altri due metodi utili sono count() per far contare al database quante istanze ci sono in un QuerySet, mentre exists() restituisce un valore boolean che indica la presenza o meno di istanze nel QuerySet:

Corso.objects.filter(categoria=categoria).count()
Corso.objects.filter(categoria=categoria).exists()

È possibile visualizzare una rappresentazione, non sempre precisa, della query che ha popolato un QuerySet stampandone l'attributo query:

qs = Corso.objects.all()
print(qs.query)
SELECT "corsi_corso"."id", "corsi_corso"."titolo", "corsi_corso"."descrizione", "corsi_corso"."categoria_id", "corsi_corso"."brochure", "corsi_corso"."creato", "corsi_corso"."aggiornato" FROM "corsi_corso"

Aggiornare istanze modelli

Per aggiornare un QuerySet si usa il metodo update():

Corso.objects.filter(titolo="Sviluppo software").update(categoria=categoria)

Usare update() su un QuerySet non implica chiamare il metodo save() di ogni oggetto presente nel QuerySet, viene generata una singola query SQL UPDATE.

Eliminare istanze modelli

Per eliminare delle istanze di modelli possiamo usare il metodo delete() sia sulla istanza che sui QuerySet:

Corso.objects.all().delete()
Categoria.objects.get().delete()

Abbiamo cancellato prima tutte le istanze di Corso e successivamente la Categoria perché abbiamo configurato Django per proteggere la cancellazione di una Categoria quando viene usata da un Corso.

Esercizi

Apri una shell Django e prova a creare, modificare ed eliminare dei modelli.

Leggi l'introduzione della documentazione dei QuerySet.

Consulta la documentazione dei Manager.

Consulta la documentazione dei Manager di relazione.

Testiamo i modelli

Testare la nostra applicazione è necessario per avere una applicazione funzionante adesso quando la scriviamo e nel futuro.

Per prima cosa quindi andiamo a creare la directory che ospiterà i file dei nostri test e creiamo il file per contenere i test per i modelli:

mkdir corsi/tests
touch corsi/tests/__init__.py
git mv corsi/tests.py corsi/tests/test_models.py

Apriamo il file corsi/tests/test_models.py con il nostro editor e cominciamo a scrivere dei test per il modello Categoria:

from django.test import TestCase

from corsi.models import Categoria


class CategoriaTestCase(TestCase):
    def test_posso_creare_categoria(self):
        categoria = Categoria.objects.create(titolo="titolo")
        self.assertTrue(categoria)

    def test_posso_stampare_categoria(self):
        categoria = Categoria.objects.create(titolo="titolo")
        self.assertEqual(str(categoria), "titolo")

    def test_categoria_mantiene_la_data_di_creazione(self):
        categoria = Categoria.objects.create(titolo="titolo")
        self.assertTrue(categoria.creato)
        data_creazione = categoria.creato
        categoria.save()
        self.assertEqual(categoria.creato, data_creazione)

    def test_categoria_aggiorna_la_data_di_aggiornamento(self):
        categoria = Categoria.objects.create(titolo="titolo")
        self.assertTrue(categoria.aggiornato)
        data_aggiornamento = categoria.aggiornato
        categoria.save()
        self.assertGreater(categoria.aggiornato, data_aggiornamento)

Il sistema di testing di Django estende quello della libreria standard di Python chiamato unittest. Il runner dei test di Django esegue i test presenti nella directory tests della nostra applicazione presenti in file il cui nome inizia per test.

I test vengono raggruppati in classi che ereditano da TestCase, i singoli test sono implementati come metodi di questa classe ed il loro nome deve cominciare con test_. Ogni test viene eseguito in una transazione del database che poi viene annullata, quindi le query fatte durante ogni test non inficiano gli altri. I test usano un database diverso rispetto a quello usato dal progetto, per default viene usato come nome quello configurato in settings.py prefissato da test_.

Facciamo girare i test con il comando:

python3 manage.py test

Quindi salviamo i nostri progressi:

git add corsi/tests
git commit -m "Aggiungiamo test per il modello Categoria"
git push origin main

Esercizi

Scrivi dei test analoghi per il modello Corso, salvali su git e pubblicali su GitHub.

Leggi l'overview sui test di Django.

Scopri nella documentazione come puoi far eseguire un singolo test.

Consulta la lista degli assert del modulo unittest di Python.

Gestiamo i contenuti in Admin

Come abbiamo visto in precedenza Django offre una interfaccia già pronta per fare operazione di inserimento, aggiornamento e cancellazione di dati chiamata Admin. Per poter visualizzare i nostri modelli nell'interfaccia di amministrazione dobbiamo registrarli (dal nome della funzione admin.register).

Registriamo quindi i modelli della nostra applicazione corsi in admin modificando corsi/admin.py:

from django.contrib import admin

from corsi.models import Categoria, Corso


@admin.register(Categoria)
class CategoriaAdmin(admin.ModelAdmin):
    search_fields = ("titolo",)


@admin.register(Corso)
class CorsoAdmin(admin.ModelAdmin):
    list_display = ("__str__", "categoria")
    list_filter = ("categoria", "docenti")
    search_fields = ("titolo", "descrizione")

L'admin creato per gestire il modello Categoria è molto semplice, l'unico attributo che abbiamo specificato è l'aver aggiunto il campo titolo a quelli cercabili. Per Corso invece, tramite l'opzione list_display abbiamo modificato i campi che vengono visualizzati nella pagina che lista tutte le istanze che abbiamo mostrando oltre a quello che restistuisce il metodo __str__ nel modello anche l'analogo metodo della Categoria associata. Infine abbiamo abbiamo usatò la funzionalità di filtro tramite l'opzione list_filter permettendo di filtrare i nostri corsi per categoria e per docenti.

Per poter accedere all'admin creiamo quindi un utente superuser con il comando:

python3 manage.py createsuperuser

Possiamo quindi far partire il server web di sviluppo con il seguente comando:

python3 manage.py runserver

Ed infine collegarci all'indirizzo http://127.0.0.1:8000/admin per poter interagire con i modelli della nostra applicazione corsi.

Una volta verificato che tutto funziona aggiorniamo il codice su git:

git add corsi/admin.py
git commit -m "Admin per modelli corsi"
git push origin main

Esercizi

Inserisci, modifica, cancella, ricerca e filtra i modelli in Admin.

Leggi la documentazione delle opzioni di ModelAdmin che abbiamo usato.

Le viste per l'applicazione dei corsi

Nei capitoli precedenti abbiamo costruito una applicazione con cui gestire un catalogo di corsi: abbiamo creato dei modelli e usando l'interfaccia di amministrazione di Django possiamo gestirne i contenuti.

L'interfaccia di amministrazione di Django però è disponibile solo per utenti staff, che possono accedere al nostro backoffice perché hanno dei permessi speciali.

In questo capitolo andremo ad estendere la nostra applicazione per essere fruibile anche ad altri utenti implementando delle viste che ci permettano di listare i corsi che abbiamo disponibili, filtrarli e vedere il dettaglio di un singolo corso. Inseriremo anche un sistema di login e registrazione per permettere agli utenti di registrarsi e quindi una volta autenticati di inserire e modificare i corsi.

La lista dei corsi

Andiamo a scrivere la vista per listare i corsi nel file corsi/views.py:

from django.views.generic import ListView

from corsi.models import Corso


class CorsoListView(ListView):
    model = Corso

Non abbiamo poi scritto molto! Abbiamo usato le viste generiche che offre Django, creando una nuova vista CorsoListView che eredita dalla vista generica ListView dove l'unico attributo che specifichiamo è il modello che vogliamo mostrare.

Fatta la vista mancano altri due pezzi: il template per fare il rendering e collegare la vista al sistema di routing.

Cominciamo dai templates. I template usati dalle viste generiche seguono tutte un pattern del tipo <applicazione>/<modello>_<azione>.html dove nel nostro caso è corsi/corso_list.html. È una convenzione e come tale il vantaggio sta nel togliere un argomento di discussione tra sviluppatori.

Come abbiamo visto in precedenza Django cerca i templates dentro ad ogni applicazione registrata, quindi andiamo a creare la directory che li contiene dentro a corsi:

mkdir -p corsi/templates/corsi

Quindi creiamo il nostro template corsi/templates/corsi/corso_list.html:

{% extends "corsi/base.html" %}

{% block content %}
    <h2>Corsi</h2>
    <ul>
        {% for corso in object_list %}
            <li>{{ corso }}</li>
        {% endfor %}
    </ul>
{% endblock %}

In questo template stiamo usando il linguaggio di templating di Django:

  • usiamo extends per estendere un altro template
  • usiamo block per definire un blocco di contenuto
  • usiamo for per ciclare su una variabile, in questo caso object_list che è quello che la ListView passa per default al template con il QuerySet delle istanze del modello
  • usiamo {{ corso }} per stampare il contenuto di una variabile, in questo caso implicitamente chiameremo il metodo __str__ di Corso.

Se abbiamo già visto un sistema di templating non ci dovrebbe essere niente di nuovo, se è la prima volta i sistemi di templating ci permettono di inserire della logica all'interno di file di testi in modo da renderci la scrittura di questi più facile.

Dal momento che stiamo estendendo il template corsi/base.html dobbiamo crearlo, anche questo andrà nella stessa directory del precedente in corsi/templates/corsi/base.html:

<html>
  <head>
    <title>{% block title %}Corsi{% endblock %}</title>
  </head>
  <body>
  {% block content %}{% endblock %}
  </body>
</html>

Con questo template dovrebbe risultare più chiaro il funzionamento di block. block permette di delimitare un blocco di testo in modo che possa essere sovrascritto da eventuali altri template. In questo esempio il template che eredita corsi/templates/corsi/base.html sovrascrive il contenuto del blocco content che vi è definito. Questo permette di poter riutilizzare buona parte dei template che si scrivono e di sostituire solo quello di cui si ha bisogno.

Fatti i template ora dobbiamo collegare la vista al sistema di routing. Per cominciare andiamo a creare un file di routing interno all'applicazione in corsi/urls.py:

from django.urls import path
from corsi import views


urlpatterns = [
    path("corsi/", views.CorsoListView.as_view(), name="corsi-list"),
]

Definiamo un path corsi/ che richiama la vista CorsoListView a cui diamo un nome corsi-list.

Per rendere queste url raggiungibili dobbiamo includerle dal file catalogo/urls.py, il file che contiene il routing del progetto:

from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('corsi/', include('corsi.urls')),
]

Il fatto che le url stiano dentro ad una variabile chiamata urlpatterns è richiesto dal sistema di routing delle url di Django.

Ora ricordiamoci di ricaricare il server web di sviluppo di Django:

python3 manage.py runserver

Puntiamo il browser all'indirizzo http://127.0.0.1:8000/corsi/corsi/ per visualizzare il nostro template renderizzato.

Salviamo su git i nostri progressi:

git add catalogo corsi
git commit -m "Aggiungiamo vista per list Corsi"
git push origin main

Esercizi

Leggi la documentazione sui templates e sul linguaggio dei templates di Django.

Il dettaglio del singolo corso

Dopo aver fatto la vista per listare i nostri corsi creiamo una vista per vedere il dettaglio di ogni singolo corso. Aggiungiamo la nuova vista in corsi/views.py.

from django.views.generic import DetailView, ListView

from corsi.models import Corso


class CorsoListView(ListView):
    model = Corso


class CorsoDetailView(DetailView):
    context_object_name = 'corso'
    queryset = Corso.objects.all()

Questa nuova vista CorsoDetailView estende la vista generica DetailView e sovrascrive due attributi:

  • context_object_name per usare corso come nome di variabile contente l'istanza di Corso passata al template, altrimenti sarebbe stata object.
  • queryset per specificare il QuerySet dal quale prendere l'istanza, utile per filtrare a monte i modelli che vogliamo poter richiamare da questa vista.

L'uso di questi attributi è abbastanza arbitrario, avremmo potuto definire model come abbiamo fatto per CorsoListView ma avremmo visto due attributi utili in meno.

Fatta la nostra vista dobbiamo aggiungerla in corsi/urls.py aggiungendola in urlpatterns:

urlpatterns = [
    path("corsi/", views.CorsoListView.as_view(), name="corsi-list"),
    path("corsi/<int:pk>/", views.CorsoDetailView.as_view(), name="corsi-detail"),
]

Qui possiamo fare attenzione a due cose: la prima è che i path sono considerati chiusi, nel senso che una vista risponderà ad un path solo se viene trovata una corrispondenza esatta; se non fosse stato così il secondo path non sarebbe stato raggiungibile.

L'altra cosa da notare è la cattura dei parametri nell'url, la sintassi è formata da due parti separate da ::

  • la prima indica il tipo, in questo esempio int per catturare solo le cifre; non è obbligatoria e se non fornita cattura il contenuto fino al prossimo /.
  • La seconda parte è il nome che diamo alla variabile passata alla vista, pk è dettata da DetailView , in questo caso la variabile è configurabile tramite l'attributo pk_url_kwarg. Le viste fatte a classi appaiono come veramente magiche quando le si usano, bisogna farci la mano.

Collegata la vista al routing possiamo creare il template che si aspetta corsi/templates/corsi/corso_detail.html:

{% extends "corsi/base.html" %}

{% block content %}
<h2>Corso: {{ corso }}</h2>
<p>Categoria:  {{ corso.categoria }}</p>
<p>Docenti: {% for docente in corso.docenti.all %}{{ docente.username }}{% endfor %}</p>
<p>Descrizione: {{ corso.descrizione }}</p>
{% endblock %}

Dovrebbe essere quasi tutto familiare, facciamo attenzione solo alla variabile che usiamo per il for, essenzialmente è lo stesso codice che avremmo scritto in una vista o nella shell ma senza le parentesi.

Abbiamo tutti i pezzi per poter chiamare la nostra vista dal browser, il fatto che dobbiamo sapere l'id del nostro modello però è un po' scomodo. Per ovviare a questo possiamo estendere il nostro modello Corso, aggiungendo un metodo get_absolute_url che tramite la funzione reverse recupera il path assoluto della vista per il singolo corso.

Usare reverse ci permette di poter cambiare in futuro i path delle nostre url senza dover aggiornarlo in tante occorrenze sparse nel nostro codice.

from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse


class Categoria(models.Model):
    titolo = models.CharField(max_length=100)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Categorie"


class Corso(models.Model):
    titolo = models.CharField(max_length=100)
    descrizione = models.TextField()
    categoria = models.ForeignKey(Categoria, null=True, blank=True, on_delete=models.PROTECT)
    docenti = models.ManyToManyField(User)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.titolo

    def get_absolute_url(self):
        return reverse("corsi-detail", args=[self.pk])

    class Meta:
        verbose_name_plural = "Corsi"

Possiamo aggiornare il template corsi/templates/corsi/corso_list.html per trasformare le occorrenze dei corsi in un link alla relativa pagina di dettaglio:

{% extends "corsi/base.html" %}

{% block content %}
    <h2>Corsi</h2>
    <ul>
        {% for corso in object_list %}
        <li><a href="{{ corso.get_absolute_url }}">{{ corso }}</a></li>
        {% endfor %}
    </ul>
{% endblock %}

Puntiamo il browser all'indirizzo http://127.0.0.1:8000/corsi/corsi/ per verificare che funzioni.

Infine ricordiamoci sempre di aggiornare il nostro codice su git:

git add corsi
git commit -m "Aggiungiamo vista per dettaglio corso"
git push origin main

Esercizi

Leggi la documentazione di DetailView.

Leggi la documentazione delle urls.

Leggi la documentazione di get_absolute_url.

Leggi la documentazione di reverse

Filtriamo i corsi

Continuiamo ad estendere la nostra vista per listare i corsi per darci la possibilità di filtrare la lista dei corsi ricercando un test nel titolo o nella descrizione di ogni corso.

Per prima cosa estendiamo il template corsi/templates/corsi/corso_list.html aggiungendo un box di ricerca:

{% extends "corsi/base.html" %}

{% block content %}
    <h2>Corsi</h2>
    <form action="" method="GET">
        <div>
        <input name="q">
        <button>Filtra</button>
        </div>
    </form>
    <ul>
        {% for corso in object_list %}
        <li><a href="{{ corso.get_absolute_url }}">{{ corso }}</a></li>
        {% endfor %}
    </ul>
{% endblock %}

Il box di ricerca farà una richiesta alla medesima pagina aggiungendo come query string una chiave q con valore il testo della nostra ricerca.

Quindi dobbiamo estendere la vista per supportare questa funzionalità. Andremo a cambiare CorsoListView in questo modo:

from django.db.models import Q

...

class CorsoListView(ListView):
    queryset = Corso.objects.all()

    def get_queryset(self):
        qs = super().get_queryset()
        query = self.request.GET.get("q")
        if query:
            return qs.filter(Q(titolo__icontains=query) | Q(descrizione__icontains=query))
        else:
            return qs

Abbiamo implementato il metodo get_queryset() che richiede l'aggiunta dell'attributo queryset. Dentro al metodo prima ci salviamo l'output dell'implementazione di default nella variabile qs, quindi prendiamo il valore della chiave q e se esiste lo usiamo per filtrare ulteriormente il QuerySet. Per filtrare il QuerySet introduciamo due nuove funzionalità, l'oggetto Q e i lookups dei campi. Gli oggetti Q ci permettono di implementare le query con filtri in OR logico (vedi |), se avessimo messo due condizioni all'interno del filter() o due filter() di seguito le condizioni sarebbero state in AND logico. I lookups invece ci permettono di filtrare nei campi in modo più specifico rispetto alla semplice uguaglianza, in questo caso abbiamo usato icontains per cercare all'interno del campo in modo case-insensitive la presenza di una stringa.

Puntiamo il browser su http://127.0.0.1:8000/corsi/corsi/ e testiamo che funzioni a dovere.

Ora che abbiamo visto che funziona possiamo scrivere un test automatico per verificare che continui a farlo. Creiamo un file corsi/tests/test_views.py con il seguente contenuto:

from django.test import TestCase
from django.urls import reverse

from corsi.models import Corso


class CorsoListViewTestCase(TestCase):
    def test_filtro_titolo(self):
        Corso.objects.create(titolo="titolo corso")
        url = reverse("corsi-list")
        response = self.client.get(f"{url}?q=tito")
        self.assertContains(response, "titolo corso")

    def test_filtro_descrizione(self):
        Corso.objects.create(titolo="titolo corso", descrizione="descrizione")
        url = reverse("corsi-list")
        response = self.client.get(f"{url}?q=descr")
        self.assertContains(response, "titolo corso")

    def test_filtro_senza_match(self):
        Corso.objects.create(titolo="titolo", descrizione="descrizione")
        url = reverse("corsi-list")
        response = self.client.get(f"{url}?q=nomatch")
        self.assertNotContains(response, "titolo corso")

In questo TestCase abbiamo introdotto due nuovi tipi di assert assertContains e assertNotContains che rispettivamente controllano che una stringa sia presente o meno nella risposta ad una chiamata. Per effettuare queste chiamate usiamo il client presente per default come attributo client all'interno della classe. Il client viene inizializzato da zero per ogni singolo test.

Facciamo girare i test con il comando:

python3 manage.py test --keepdb

Lo switch --keepdb non fa cancellare e creare a Django un nuovo database nel quale far girare i test se ne esiste già uno.

Per concludere aggiorniamo i nostri progressi in git:

git add corsi
git commit -m "Aggiungiamo filtro sulla lista dei corsi"
git push origin main

Esercizi

Replichiamo le due viste, le url, i template ed i test che abbiamo creato per i corsi anche per le categorie. Salva i progressi su git e pubblicali su GitHub.

Guarda come è fatto l'oggetto HttpRequest di Django nella documentazione

Guarda quali altri lookups sono disponibili nella documentazione

Approfondisci l'oggetto Q nella documentazione

Leggi la documentazione dei tool di testing e di assertContains

Inseriamo e modifichiamo i corsi

Dopo le viste per listare i corsi e quella per visualizzare il singolo corso mancano all'appello altre due viste: la prima per creare un nuovo corso e l'altra per modificarne uno già presente.

Queste viste avranno entrambe bisogno di un form che andremo ad implementare nel file corsi/forms.py:

from django import forms

from corsi.models import Corso


class CorsoForm(forms.ModelForm):
    class Meta:
        model = Corso
        fields = ["titolo", "descrizione", "categoria", "docenti"]

Django offre una classe speciale di form chiamati ModelForm, form che sono collegati ad modello specifico. Questo legame permette di poter generare il form automaticamente dal modello, senza bisogno di definire i campi del form. Abbiamo implementato CorsoForm, un ModelForm per il modello Corso. Oltre a specificare il modello in model, l'altro attributo che abbiamo specificato è fields che è un attributo obbligatorio che serve per listare tutti i campi del modello che vogliamo esporre nel nostro form. In questo caso abbiamo inserito tutti i campi che non vengono aggiornati automaticamente, escludendo quindi i campi con la data di creazione e di aggiornamento.

Fatto il form possiamo procedere con le due viste che ci serviranno in corsi/views.py:

from django.db.models import Q
from django.urls import reverse
from django.views.generic import CreateView, DetailView, ListView, UpdateView

from corsi.forms import CorsoForm
from corsi.models import Corso

...

class CorsoCreateView(CreateView):
    model = Corso
    form_class = CorsoForm


class CorsoUpdateView(UpdateView):
    model = Corso
    form_class = CorsoForm

Le due viste estendono rispettivamente CreateView e UpdateView e per entrambe definiamo gli stessi attributi: model per il modello e form_class per definire quale form usare. Se il form inviato è corretto l'utente sarà rediretto alla pagina di dettaglio del corso perché Django per default redirige verso l'url restituita dal metodo get_absolute_url(). Se il form non è ritenuto valido verranno segnalati gli errori nella medesima pagina.

Le viste necessitano di un template per renderizzare il form, per default il nome è composto dal nome del modello e dal suffisso form.html, nel nostro caso corsi/templates/corsi/corso_form.html:

{% extends "corsi/base.html" %}

{% block content %}
<form method="post">{% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="Salva">
</form>
{% endblock %}

Come possiamo vedere il rendering è demandata al methodo as_p() del form che è una API dei form di Django per renderizzare i campi dei form dentro a dei tag p. L'altra novità è il tag csrf_token, si tratta di un tag che renderizza un campo del form contenente un token per validare che la richiesta sia stata fatta dallo stesso sito di provenienza. Se il token inviato non risulta corretto, il form non viene considerato valido.

Possiamo quindi collegare le nostre viste al sistema di routing:

urlpatterns = [
    path("corsi/", views.CorsoListView.as_view(), name="corsi-list"),
    path("corsi/<int:pk>/", views.CorsoDetailView.as_view(), name="corsi-detail"),
    path("corsi/crea/", views.CorsoCreateView.as_view(), name="corsi-create"),
    path("corsi/<int:pk>/aggiorna/", views.CorsoUpdateView.as_view(), name="corsi-update"),
]

Quindi se puntiamo il browser all'indirizzo http://127.0.0.1:8000/corsi/corsi/crea/ dovremmo vedere il form per l'inserimento di un nuovo corso.

La vista per modificare un corso invece è un scomoda da chiamare direttamente, quindi aggiungiamo i link a queste viste nei template delle altre viste.

Aggiungiamo al template del dettaglio un link alla vista di modifica, visto che ci siamo aggiungiamo anche un linka alla pagina che lista i corsi:

{% extends "corsi/base.html" %}

{% block content %}
<h2>Corso: {{ corso }}</h2>
<p><a href="{% url 'corsi-update' corso.pk %}">Modifica</a></p>

<p>Categoria:  {{ corso.categoria }}</p>
<p>Docenti: {% for docente in corso.docenti.all %}{{ docente.username }}{% endfor %}</p>
<p>Descrizione: {{ corso.descrizione }}</p>

<p><a href="{% url 'corsi-list' %}">Torna alla lista</a></p>
{% endblock %}

Nel template che lista i corsi invece andiamo ad aggiungere un link alla vista di creazione:

{% extends "corsi/base.html" %}

{% block content %}
    <h2>Corsi</h2>
    <form action="" method="GET">
        <div>
        <input name="q">
        <button>Filtra</button>
        </div>
    </form>
    <ul>
        {% for corso in object_list %}
        <li><a href="{{ corso.get_absolute_url }}">{{ corso }}</a></li>
        {% endfor %}
    </ul>
    <p><a href="{% url 'corsi-create' %}">Crea nuovo corso</a></p>
{% endblock %}

Ora possiamo testare facilmente dal nostro browser che tutto funzioni.

Possiamo passare a scrivere i test per assicurarci che il codice continui a funzionare anche in futuro, apriamo il file corsi/tests/test_views.py ed aggiungiamo dei nuovi testcase:

from django.contrib.auth.models import User

...


class CorsoCreateViewTestCase(TestCase):
    def test_posso_creare_nuovo_corso(self):
        user = User.objects.create_user("username")
        url = reverse("corsi-create")
        data = {
            "titolo": "titolo",
            "descrizione": "descrizione",
            "docenti": [user.pk],
        }
        response = self.client.post(url, data=data)
        corso = Corso.objects.get(titolo="titolo", descrizione="descrizione")
        redirect_url = reverse("corsi-detail", args=[corso.pk])
        self.assertRedirects(response, redirect_url)


class CorsoUpdateViewTestCase(TestCase):
    def test_posso_aggiornare_corso(self):
        user = User.objects.create_user("username")
        corso = Corso.objects.create(titolo="titolo", descrizione="descrizione")
        data_aggiornamento = corso.aggiornato
        url = reverse("corsi-update", args=[corso.pk])
        data = {
            "titolo": "nuovo titolo",
            "descrizione": "nuova descrizione",
            "docenti": [user.pk],
        }
        response = self.client.post(url, data=data)
        redirect_url = reverse("corsi-detail", args=[corso.pk])
        self.assertRedirects(response, redirect_url)
        corso.refresh_from_db()
        self.assertGreater(corso.aggiornato, data_aggiornamento)

    def test_corso_non_viene_aggiornato_se_payload_invalido(self):
        corso = Corso.objects.create(titolo="titolo", descrizione="descrizione")
        url = reverse("corsi-update", args=[corso.pk])
        data_aggiornamento = corso.aggiornato
        data = {}
        response = self.client.post(url, data=data)
        self.assertEqual(response.status_code, 200)
        corso.refresh_from_db()
        self.assertEqual(corso.aggiornato, data_aggiornamento)

Stiamo testando che sia possibile creare un nuovo corso, che sia possibile aggiornarne uno già presente in database e che mandando dei dati invalidi non vengano aggiornati dei corsi già presenti. Abbiamo introdotto un nuovo tipo di assert AssertRedirects che controlla che la response di una chiamata abbia fatto un redirect ad una url specifica. Abbiamo anche introdotto l'uso delle chiamate post dal client di test, che prendono come parametro data i dati da passare alla vista sotto forma di dizionario.

Testiamo anche il form e creiamo il file corsi/test/test_forms.py:

from django.contrib.auth.models import User
from django.test import TestCase

from corsi.forms import CorsoForm
from corsi.models import Categoria, Corso


class CorsoFormTestCase(TestCase):
    def test_posso_creare_un_corso(self):
        user = User.objects.create_user("username")
        data = {
            "titolo": "titolo",
            "descrizione": "descrizione",
            "docenti": [user.pk],
        }
        form = CorsoForm(data)
        self.assertTrue(form.is_valid())
        corso = form.save()
        self.assertTrue(corso)

    def test_posso_aggiornare_un_corso(self):
        categoria = Categoria.objects.create(titolo="categoria")
        corso = Corso.objects.create(titolo="titolo", descrizione="descrizione")
        user = User.objects.create_user("username")
        data = {
            "titolo": "nuovo titolo",
            "descrizione": "nuova descrizione",
            "categoria": categoria.pk,
            "docenti": [user.pk],
        }
        form = CorsoForm(data, instance=corso)
        self.assertTrue(form.is_valid())
        corso = form.save()
        self.assertEqual(corso.titolo, "nuovo titolo")
        self.assertEqual(corso.descrizione, "nuova descrizione")
        self.assertEqual(corso.categoria, categoria)
        self.assertQuerysetEqual(corso.docenti.all(), [user])

Abbiamo aggiunto dei test per verificare che tramite il form CorsoForm possiamo creare una nuova istanza di Corso e possiamo modificarla. Abbiamo introdotto l'uso delle API dei ModelForm usando due metodi:

  • is_valid() che controlla la validità dei dati passati al form e restituisce un booleano. Nel caso il form non sia valido riempie l'attributo errors del form con gli errori.
  • save() che salva il contenuto del form in nuova istanza oppure nell'istanza che gli viene passata dal parametro instance. Abbiamo anche introdotto l'uso di un nuovo tipo di assert AssertQuerysetEqual che controlla che un queryset sia uguale ad una lista di valori.

Facciamo girare i test con il comando:

python3 manage.py test corsi --keepdb

Tutti i nostri test passano, possiamo aggiornare il codice su git:

git add corsi
git commit -m "Aggiungiamo viste per creazione ed aggiornamento corsi"
git push origin main

Esercizi

Replichiamo le nuove viste, le url, i template ed i test che abbiamo creato per i corsi anche per le categorie. Salva i progressi su git e pubblicali su GitHub.

Consulta la documentazione dei ModelForm.

I form sono un argomento immenso, consulta la documentazione ufficiale.

Guarda la documentazione della CreateView e UpdateView e un articolo specifico su come vengono gestiti i form tramite viste generiche.

Se vuoi saperne più su Cross-Site-Request-Forgery consulta la documentazione.

Guarda la documentazione di AssertRedirects e AssertQuerysetEqual per scoprire quali opzioni supportano.

Autentichiamo le viste

Lasciare la creazione e la modifica dei corsi a qualsiasi utente perlopiù sconosciuto non è una buona idea. Facciamo in modo che le viste di creazione e di modifica richiedano un account sulla nostra istanza Django.

Per fare questo apriamo nel nostro editor corsi/views.py e modifichiamo le classi in questo modo:

from django.contrib.auth.mixins import LoginRequiredMixin

...


class CorsoCreateView(LoginRequiredMixin, CreateView):
    model = Corso
    form_class = CorsoForm


class CorsoUpdateView(LoginRequiredMixin, UpdateView):
    model = Corso
    form_class = CorsoForm

Non abbiamo fatto altro che modificare le nostre classi per estendere anche il mixin LoginRequiredMixin che rende le nostre viste accessibili solo ad utenti che si sono autenticati nella nostra applicazione.

Login

Per permettere ai nostri utenti di autenticarsi però dobbiamo fornirgli una vista di login. Fortunatamente Django ne include già una, quello che dobbiamo fare è aggiungerla nel sistema di routing delle url.

Apriamo il file catalogo/urls.py e modifichiamolo così:

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('corsi/', include('corsi.urls')),
    path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
]

Configuriamo la vista per reindirizzare l'utente, se non specificato diversamente, alla lista dei corsi aggiungendo nel file catalogo/settings.py:

LOGIN_REDIRECT_URL = 'corsi-list'

Questa vista di login, al pari di quelle che abbiamo scritto noi, richiede un template. Fino ad ora abbiamo inserito i nostri template all'interno di una directory specifica della nostra applicazione corsi. In questo caso però il login è una funzionalità comune a tutto il progetto. Django ci permette di specificare una lista di directory dove cercare i nostri templates fuori dalle nostre applicazioni.

Creiamo una directory nella root del progetto:

mkdir templates

Quindi modifichiamo l'attributo TEMPLATES in catalogo/settings.py:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / Path('templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

Abbiamo modificato la chiave DIRS aggiungendo la directory che abbiamo creato. Da notare la creazione del path della directory usando / per unire due percorsi fomati da istanze di Path del modulo pathlib.

La vista di login si aspetta un template con percorso registration/login.html quindi creiamo la directory registration dentro a templates:

mkdir -p templates/registration

E creiamo il template templates/registration/login.html:

{% extends "corsi/base.html" %}

{% block content %}

{% if form.errors %}
<p>Lo username e/o la password non sembrano corretti. Per favore riprova.</p>
{% endif %}

{% if next %}
    {% if user.is_authenticated %}
    <p>Il tuo account non ha accesso a questa pagina. Per continuare esegui il login con un account
    che ha i permessi necessari.</p>
    {% else %}
    <p>Per favore esegui il login per vedere questa pagina.</p>
    {% endif %}
{% endif %}

<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<table>
<tr>
    <td>{{ form.username.label_tag }}</td>
    <td>{{ form.username }}</td>
</tr>
<tr>
    <td>{{ form.password.label_tag }}</td>
    <td>{{ form.password }}</td>
</tr>
</table>

<input type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>
{% endblock %}

Andiamo ad analizzare questo template pezzo per pezzo.

Usiamo form.errors per capire se il nostro form si è rivelato valido o meno, se errors è valorizzato il form non è valido e quindi mostriamo un messaggio di errore.

Controlliamo la valorizzazione della variabile next per distinguere il caso in cui siamo stati reindirizzati al login o ci siamo arrivati direttamente. Se next è valorizzata distinguiamo inoltre se siamo già loggati e quindi non abbiamo i permessi necessari per vedere una vista o se non siamo ancora loggati.

Il form invece differisce da quello che abbiamo creato in precedenza perché il rendering del form viene fatto manualmente campo per campo; possiamo notare come mostriamo separatamente per ogni campo la label ed il campo di input.

Con il template possiamo far puntare il nostro browser su http://127.0.0.1:8000/accounts/login ed usare i dati del nostro utente per verificare che funzioni.

Logout

Fatto il login dobbiamo implementare anche il logout.

Cominciamo con l'aggiungere la vista nelle nostre url:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('corsi/', include('corsi.urls')),
    path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
    path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
]

Al pari della vista di login anche quella di logout ci permette di specificare una vista verso la quale reindirizzare l'utente a logout avvenuto. Configuriamo catalogo/settings.py per reindirizzare l'utente anche in questo caso verso la lista dei corsi:

LOGOUT_REDIRECT_URL = 'corsi-list'

Per provare la vista modifichiamo il nostro template corsi/templates/corsi/base.html inserendo un link alla vista di logout nel caso l'utente sia autenticato:

<html>
  <head>
    <title>{% block title %}Corsi{% endblock %}</title>
  </head>
  <body>
  {% if user.is_authenticated %}<p><a href="{% url 'logout' %}">Logout</a></p>{% endif %}
  {% block content %}{% endblock %}
  </body>
</html>

Testiamo che il link sia funzionante tramite il nostro browser.

Ora è il momento di far girare i nostri test:

python3 manage.py test --keepdb

Oops! abbiamo una bella serie di errori e fallimenti:

ERROR: test_posso_creare_nuovo_corso (corsi.tests.test_views.CorsoCreateViewTestCase)
FAIL: test_corso_non_viene_aggiornato_se_payload_invalido (corsi.tests.test_views.CorsoUpdateViewTestCase)
FAIL: test_posso_aggiornare_corso (corsi.tests.test_views.CorsoUpdateViewTestCase)

Tutti i test che falliscono sono test che riguardano le viste. Per ogni test fallito viene stampato se c'è stato un errore nell'esecuzione del test (ERROR), come nel primo caso, oppure se il test è semplicemente fallito perché un'asserzione non ha dato il risultato sperato (FAIL). Quindi per ogni test vediamo il nome del test e separatamente il TestCase che lo include. Questa notazione è anche quella che ci permette di eseguire i test ed i TestCase singolarmente, come ad esempio:

# singolo TestCase
python3 manage.py test --keepdb corsi.tests.test_views.CorsoCreateViewTestCase
# singolo test
python3 manage.py test --keepdb corsi.tests.test_views.CorsoCreateViewTestCase.test_posso_creare_nuovo_corso

Per ogni test inoltre abbiamo una traceback cioè lo stato dell'esecuzione al momento del fallimento in ordine cronologico.

Andiamo a vedere nel dettaglio perché i test sono falliti. Il primo test fallisce perché la chiamata alla vista di creazione del corso non ha creato un nuovo corso, il secondo fallisce perché lo status code HTTP della risposta alla chiamata di aggiornamento è cambiato ed infine il terzo è fallito perché l'url a cui si viene reindirizzati non è quella che ci aspettiamo.

I test hanno fatto il loro lavoro e hanno evidenziato dei cambiamenti di comportamento. Le viste di creazione e modifica dei corsi infatti ora richiedono che l'utente sia autenticato.

Apriamo il file corsi/tests/test_views.py per sistemarli:

class CorsoCreateViewTestCase(TestCase):
    def test_posso_creare_nuovo_corso(self):
        user = User.objects.create_user("username", password="password")
        url = reverse("corsi-create")
        data = {
            "titolo": "titolo",
            "descrizione": "descrizione",
            "docenti": [user.pk],
        }
        self.client.login(username="username", password="password")
        response = self.client.post(url, data=data)
        corso = Corso.objects.get(titolo="titolo", descrizione="descrizione")
        redirect_url = reverse("corsi-detail", args=[corso.pk])
        self.assertRedirects(response, redirect_url)

Abbiamo aggiunto un password al nostro utente in modo da poter effettuare il login tramite il metodo login() del client dei test.

Tutti i test si correggono con lo stesso intervento, sistemali in autonomia

python3 manage.py test --keepdb

Ottimo, i vecchi test passano! Aggiungiamone di nuovi per testare che le viste sono disponibili solo per gli utenti autenticati:

class CorsoCreateViewTestCase(TestCase):
    ...

    def test_la_vista_richiede_autenticazione(self):
        url = reverse("corsi-create")
        response = self.client.post(url, data={})
        redirect_url = reverse("login") + f"?next={url}"
        self.assertRedirects(response, redirect_url)


class CorsoUpdateViewTestCase(TestCase):
    ...

    def test_la_vista_richiede_autenticazione(self):
        corso = Corso.objects.create(titolo="titolo", descrizione="descrizione")
        url = reverse("corsi-update", args=[corso.pk])
        response = self.client.post(url, data={})
        redirect_url = reverse("login") + f"?next={url}"
        self.assertRedirects(response, redirect_url)

Verifichiamo che i test passano:

python3 manage.py test --keepdb

Quindi salviamo i nostri progressi in git:

git add corsi catalogo templates
git commit -m "Proteggiamo creazione ed aggiornamento da login"
git push origin main

Esercizi

Rendiamo le viste di creazione ed aggiornamento delle categoria disponibili solo per gli utenti autenticati ed aggiorniamo i test. Salva i progressi su git e pubblicali su GitHub.

Consulta la documentazione di LoginRequiredMixin.

Consulta la documentazione di LoginView.

Leggi la documentazione di LOGIN_REDIRECT_URL.

Guarda quali valori vengono messi per default nel contesto dei template

Consulta la documentazione di LogoutView.

Leggi la documentazione di LOGOUT_REDIRECT_URL.

La registrazione degli utenti

L'ultimo pezzo per completare il puzzle è permettere agli utenti di registrarsi in autonomia. Non implenteremo un flusso di registrazione in autonomia ma ci affideremo ad una applicazione esterna, django-registration.

Per prima cosa installmia l'ultima versione di django-registration, al momento della scrittura siamo alla versione 3.1.2:

pip install django-registration

Quando aggiungiamo delle applicazioni esterne è buona norma congelare le dipendenze in modo da rendere il nostro progetto riproducibile. Nel mondo Python la gestione delle dipendenze è abbastanza in fermento e ci sono diversi progetti concorrenti, in questo corso useremo le funzionalità previste da pip.

Per congelare le dipendenze con pip possiamo usare il comando pip freeze:

pip freeze

Che stamperà un output simile a questo:

asgiref==3.3.4
confusable-homoglyphs==3.2.0
Django==3.2.2
django-registration==3.1.2
mysqlclient==2.0.3
pytz==2021.1
sqlparse==0.4.1

Per salvare una copia di questo file possiamo redirigere l'output su un file requirements.txt come da convenzione:

pip freeze > requirements.txt

Per installare in un altro ambiente virtuale le stesse dipendenze si usa pip install -r:

pip install -r requirements.txt

Salviamo il file requirements.txt in git:

git add requirements.txt
git commit -m "Aggiungiamo file requirements.txt"
git push origin main

Salvate le dipendenze ora passiamo ad integrare django-registration nella nostra applicazione. Vogliamo implementare il flusso a 2 passi documentato nella documentazione ufficiale. Questo flusso prevede che l'utente inserisca i campi richiesti dalla registrazione e confermi il corretto inserimento dell'indirizzo email tramite una email di conferma. Faremo i primi passi assieme e lascieremo la finalizzazione come esercizio.

Per prima cosa dobbiamo configurare una variabile nella nostra configurazione per decidere per quanto tempo lasciamo la possibilità agli utenti di finalizzare la registrazione, 7 giorni è un buon valore.

Apriamo catalogo/settings.py ed inseriamo:

ACCOUNT_ACTIVATION_DAYS = 7

Visto che la conferma dell'iscrizione viene inviata tramite email e che configurare un server di posta ci potrerebbe via del tempo utile configuriamo Django per usare un sistema di invio email che stampi il contenuto delle email in console. Dovremmo vedere le email inviate nella stessa shell in cui facciamo girare il server di sviluppo con runserver.

Sempre in catalogo/settings.py aggiungiamo:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

Quindi colleghiamo le viste di django-registration in catalogo/urls.py:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('corsi/', include('corsi.urls')),
    path('accounts/', include('django_registration.backends.activation.urls')),
    path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
    path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
]

django-registration richiede diversi template da implementare, creiamo nella nostra directory dei templates una directory per contenerli:

mkdir templates/django_registration

Ora manca solo creare i template; segui la documentazione ufficiale per sapere quali creare, a cosa servono e quali variabili ricevono.

Dopo aver creato i templates puntando il browser all'indirizzo http://127.0.0.1:8000/accounts/register/ possiamo testare il processo di registrazione.

Una volta che tutto funziona aggiorniamo i nostri progressi su git:

git add catalogo templates
git commit -m "Aggiunta registrazione"
git push origin main

Esercizi

Se vuoi saperne di più sull'invio delle email in Django consulta la documentazione e anche quella specifica dei test.

Form più belli

Per fare la nostra applicazione se non più bella almeno più ordinata esteticamente useremo il framework css Bootstrap 4 integrato in django-crispy-forms.

django-crispy-forms è una applicazione Django che offre sia un rendering dei form che sfrutta dei framework css come Bootstrap che una api per creare dei form programmaticamente.

Per prima cosa installiamo django-crispy-forms:

pip install django-crispy-forms

Ogni volta che aggiungiamo una dipendenza dobbiamo aggiornare il nostro file requirements.txt:

pip freeze > requirements.txt

Ora aggiungiamo django-crispy-forms nella INSTALLED_APPS in catalogo/settings.py:

INSTALLED_APPS = [
    ...
    'crispy_forms',
]

Quindi sempre in catalogo/settings.py configuriamo django-crispy-forms per usare l'integrazione con Bootstrap 4:

CRISPY_TEMPLATE_PACK = 'bootstrap4'

Scarichiamo Bootstrap 4.6.0 ed estriamo il contenuto del pacchetto zip. Bootstrap 4 ha come dipendenza jQuery quindi scarichiamo l'ultima versione slim disponibile, al momento jQuery 3.6.0.

Nel nostro progetto invece andiamo a creare la directory che conterrà i file statici condivisi dalla nostra applicazione:

mkdir static
mkdir static/css static/js

Copiamo quindi i file css/bootstrap.min.css e js/bootstrap.bundle.min.js dalla directory di Boostrap rispettivamente in static/css e in static/js. Copiamo jquery-3.6.0.slim.min.js in static/js.

La nostra directory static apparirà così:

static
├── css
│   └── bootstrap.min.css
└── js
    ├── bootstrap.bundle.min.js
    └── jquery-3.6.0.slim.min.js

Ora dobbiamo configurare Django per dirgli di cercare i nostri file statici nella directory static, creiamo il seguente vicino alla configurazione STATIC_URL in catalogo/settings.py:

STATICFILES_DIRS = [
    BASE_DIR / 'static',
]

STATIC_URL indica il path delle richieste che Django deve interpretare come file statici.

Ora che abbiamo configurato i file statici andiamo per prima cosa ad aggiornare il nostro template di base presente in corsi/templates/corsi/base.html per includere i file CSS e JavaScript di Bootstrap:

<html>
  <head>
    <title>{% block title %}Corsi{% endblock %}</title>
    <link rel="stylesheet" href="/static/css/bootstrap.min.css">
    <script src="/static/js/jquery-3.6.0.slim.min.js"></script>
    <script src="/static/js/bootstrap.bundle.min.js"></script>
  </head>
  <body>
    <div class="container">
      <div class="row">
        <div class="col-12">
        {% if user.is_authenticated %}<p><a href="{% url 'logout' %}">Logout</a></p>{% endif %}
        {% block content %}{% endblock %}
        </div>
      </div>
    </div>
  </body>
</html>

Visto che ci siamo abbiamo anche creato dei container con delle classi di Bootstrap perché siano più presentabili.

Se puntiamo il browser sulla nostra lista dei corsi dovremmo già poter vedere delle differenze.

La prima modalità di utilizzo di django-crispy-forms è quella tramite il filtro crispy. Lo andremo ad usare nel template di login templates/registration/login.html per sostituire il layout tabellare fatto a mano:

{% extends "corsi/base.html" %}
{% load crispy_forms_tags %}

{% block content %}

{% if form.errors %}
<p>I tuoi username e password non sembrano corretti. Per favore riprova.</p>
{% endif %}

{% if next %}
    {% if user.is_authenticated %}
    <p>Il tuo account non ha accesso a questa pagina. Per continuare esegui il login con un account
    che ha i permessi necessari.</p>
    {% else %}
    <p>Per favore esegui il login per vedere questa pagina.</p>
    {% endif %}
{% endif %}

<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>
{% endblock %}

crispy è un filtro perché usa la sintassi con il pipe (|) per prendere il form come suo parametro. I templatetags forniti dalle applicazioni sono caricati manualmente tramite load.

Controlliamo dal browser come si renderizza il form, decisamente in modo migliore!

Un'altra modalità di uso di django-crispy-forms è quella tramite il tag crispy che permette di renderizzare il form completamente, compresi i tag <form>, il token csrf ed il bottone di invio.

Questa modalità richiede un intervento sulla classe del form, andiamo a modificare CorsoForm aprendo il file corsi/forms.py:

from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms

from corsi.models import Corso


class CorsoForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.form_id = "corso-form"
        self.helper.form_method = "post"
        self.helper.form_action = ""
        self.helper.add_input(Submit('submit', 'Salva'))

    class Meta:
        model = Corso
        fields = ["titolo", "descrizione", "categoria", "docenti"]

In questo modo abbiamo specificato di renderizzare il form con id corso-form, di inviare il form come POST sulla stessa url tramite un botton Salva.

Per renderizzare il form modifichiamo il template corsi/templates/corsi/corso_form.html per usare il tag crispy:

{% extends "corsi/base.html" %}
{% load crispy_forms_tags %}

{% block content %}
{% crispy form %}
{% endblock %}

Controlliamo dal browser come si renderizza il form di modifica dei corsi.

Salviamo i nostri progressi in git:

git add static catalogo templates corsi requirements.txt
git commit -m "Usiamo crispy-forms e Bootstrap 4 per fare il rendering dei forms"
git push origin main

Esercizi

Converti il form di registrazione per vedere la differenza che fa il rendering di django-crispy-forms.

Consulta la documentazione sui file statici.

Leggi la documentazione di django-crispy-forms sul filtro crispy e sul tag crispy.

Upload dei file

Dopo aver visto come gestire i file statici del nostro progetto ora andiamo a vedere come è possibile gestire file dinamici caricati dagli utenti. In questa sezione vogliamo estendere il modello Corso per poter caricare un pdf contenente la brochure del corso.

Per prima cosa andiamo a creare nella directory principale del progetto la directory che conterrà i file caricati:

mkdir uploads

Quindi andiamo a configurare in catalogo/settings.py il path alla directory che abbiamo appena creato come MEDIA_ROOT ed il path da prefissare nelle url dai quali servire i file caricati come MEDIA_URL:

MEDIA_ROOT = BASE_DIR / 'uploads'
MEDIA_URL = '/media/'

Per poter accedere ai file caricati in sviluppo possiamo usare l'helper static che servirà i file presenti in MEDIA_ROOT alle url prefissate da MEDIA_URL aggiungendolo alle url in catalogo/urls.py:

from django.conf import settings
from django.conf.urls.static import static
...

urlpatterns = [
...
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

static funziona esclusivamente quando DEBUG è attivato nella nostra configurazione.

Una volta configurato il nostro progetto per gestire i file caricati dagli utenti andiamo a modificare il modello Corso aggiungendo il nuovo campo brochure di tipo FileField in corsi/models.py:

class Corso(models.Model):
    titolo = models.CharField(max_length=100)
    descrizione = models.TextField()
    categoria = models.ForeignKey(Categoria, null=True, blank=True, on_delete=models.PROTECT)
    docenti = models.ManyToManyField(User)
    brochure = models.FileField(upload_to="corsi/corso/brochure/", null=True, blank=True)

    creato = models.DateTimeField(auto_now_add=True)
    aggiornato = models.DateTimeField(auto_now=True)

    def get_absolute_url(self):
        return reverse("corsi-detail", args=[self.pk])

    def __str__(self):
        return self.titolo

    class Meta:
        verbose_name_plural = "Corsi"

Per il campo brochure andiamo a configurare il parametro upload_to per specificare il path dentro a MEDIA_ROOT nel quale salvare i file caricati; abbiamo usato corsi/corso/brochure per identificare rispettivamente l'applicazione, il modello e quindi il campo dove abbiamo associato il file caricato. Rendiamo il campo nullable perché lo stiamo aggiungendo quando abbiamo già delle istanze di Corso in database.

Quindi creiamo la migrazione ed applichiamola:

python3 manage.py makemigrations
python3 manage.py migrate

Il prossimo passo consiste nell'aggiornare il form del corso. Aggiorniamo CorsoForm in corsi/forms.py:

class CorsoForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.form_id = "corso-form"
        self.helper.form_method = "post"
        self.helper.form_action = ""
        self.helper.attrs = {"enctype": "multipart/form-data"}
        self.helper.add_input(Submit('submit', 'Salva'))

    class Meta:
        model = Corso
        fields = ["titolo", "descrizione", "categoria", "docenti", "brochure"]

Abbiamo modificato il form per permettere di modificare il nuovo campo brochure aggiungendolo in fields e abbiamo aggiunto tramite l'attributo attrs dell'istanza di FormHelper l'attributo enctype con valore multipart/form-data in modo da permettere l'upload di file in aggiunti ai normali campi del form.

Dopo il form aggiorniamo anche il template di dettaglio del corso per visualizzare il valore del nuovo campo brochure aggiornando il file corsi/templates/corsi/corso_detail.html:

{% extends "corsi/base.html" %}

{% block content %}
<h2>Corso: {{ corso }}</h2>
<p><a href="{% url 'corsi-update' corso.pk %}">Modifica</a></p>

<p>Categoria: {{ corso.categoria }}</p>
<p>Docenti: {% for docente in corso.docenti.all %}{{ docente.username }}{% endfor %}</p>
<p>Descrizione: {{ corso.descrizione }}</p>
<p>Brochure: {% if corso.brochure %}
<a href="{{ corso.brochure.url }}">Scarica</a>
{% else %}
Nessuna brochure disponibile
{% endif %}</p>

<p><a href="{% url 'corsi-list' %}">Torna alla lista</a></p>
{% endblock %}

In questo caso stiamo mostrando un link al file quando presente o in alternativa un messaggio di assenza del file. Facciamo attenzione al fatto che viene valutata la presenza del file tramite l'attributo brochure mentre l'url è disponibile come attributo url solo quando il campo ha un file associato.

Possiamo verificare di poter caricare una brochure puntando il nostro browser all'indirizzo http://127.0.0.1:8000/corsi/corsi e aggiungendo od aggiornando un corso.

Dal momento che i file caricati dagli utenti non devono essere salvati in git, aggiungiamo la directory uploads ai file che git deve ignorare:

echo "uploads/" >> .gitignore

Infine aggiorniamo i nostri progressi su git e GitHub:

git add corsi catalogo .gitignore
git commit -m "Aggiungiamo il campo brochure a Corso"
git push origin main

Esercizi

Leggi la documentazione delle configurazioni MEDIA_ROOT e MEDIA_URL.

Leggi la reference del campo FileField e un articolo specifico sulla gestione dei file in Django.

Consulta la documentazione per scoprire come è implementato l'upload dei file.

Estendiamo l'applicazione corsi

Un ultimo tocco per concludere la nostra applicazione corsi sarà quello di aggiungere un sistema di commenti (semplificato!) per permettere agli utenti autenticati di commentare uno specifico corso.

Il sistema di commenti deve proporre un form nella pagina di dettaglio di un corso per poter inserire un commento. Di ogni commento vogliamo tracciarne l'autore e vogliamo visualizzare i commenti ordinati in modo decrescente per data di inserimento. I commenti non devono poter essere cancellati, ma possono essere aggiornati dal loro autore.

Questo sistema può essere implementato usando le competenze acquisite fino ad ora, si raccomanda la lettura di questo capitolo per creare una esperienza utente migliore e di questo per scoprire come aggiungere altre variabili nel contesto della vista di dettaglio.

Una volta implementata la funzionalità vanno ovviamente aggiunti i test e salvati i progressi in git.

Estendiamo il nostro progetto

In questo capitolo dedicato all'autoapprendimento saranno proposti degli esercizi per fare pratica su quanto visto finora. Non mancheranno degli spunti per aggiungere qualche nuova conoscenza.

Gestione dei docenti

L'applicazione corsi permette a qualsiasi utente registrato di essere inserito come docente di un corso. Vogliamo creare un sistema più raffinato per separare i docenti dal resto degli utenti registrati.

Creiamo una nuova applicazione chiamata profili per gestire un nuovo modello Profilo da mettere in relazione 1-a-1 tramite un campo OneToOneField al modello User.

Nel modello Profilo vogliamo tracciare:

  • il paese di provenienza
  • un flag per distinguere i docenti dal resto degli utenti

Per avere una lista dei paesi si può sfruttare il pacchetto pycountry da usare in combinazione con il parametro choices di un campo CharField.

Ogni utente può accedere tramite delle viste a dei form di inserimento e di modifica del proprio profilo. Da questi form però è escluso il flag per distinguere i docenti dagli utenti normali. Questo flag può essere settato solo tramite l'interfaccia di amministrazione.

Una volta che gli utenti possono essere profilati come docenti dobbiamo aggiornare l'applicazione corsi per limitare la scelta dei docenti selezionabili tramite il parametro limit_choices_to del campo ManyToManyField a solo quelli che hanno il flag attivato.

Ricordati di testare modelli, form e viste e di salvare i tuoi progressi su git.

Una homepage

Ora che abbiamo due applicazioni nel nostro progetto farebbe comodo avere anche una homepage per avere tutto a portata di mano.

Creiamo una nuova applicazione homepage che contenga una vista da usare come homepage per il nostro progetto.

Questa vista deve contenere:

  • un link alla pagina del proprio profilo per poterlo modificare o creare
  • un link alla lista dei corsi
  • la lista di tutti gli username degli utenti presenti in database indicando inoltre se sono docenti

Ricordati di testare la vista e di salvare i tuoi progressi su git.

Approfondimenti

In questo capitolo affronteremo tutti quegli argomenti utili da conoscere ma che non è detto che tutti affronteranno nel corso della loro esperienza sviluppando applicazioni Django.

Per quanto possibile ogni sezione può essere affrontata separatamente dalle altre.

Altri accessi al database

In alcuni casi potrebbe far comodo avere un accesso più diretto al database, per esempio quando abbiamo delle query veramente complicate che non riusciamo ad esprimere in modo efficiente tramite l'ORM. Oppure se stiamo facendo il porting di un applicativo verso Django potrebbe far comodo riusare, si spera temporaneamente, la collezione di query SQL già in nostro possesso.

Un altro caso in cui ci si potrebbe trovare è quello di dover accedere ad database legacy per poter reperire dei dati che dobbiamo ancora portare a del nuovo codice. Oppure potrebbe capitare di dover leggere dei dati elaborati da un'altra applicazione e non aver altro modo di accedere oltre al database.

Eseguire query SQL

Potrebbe capitare di non riuscire ad esprimere delle query tanto efficienti come quelle espresse scrivendo l'SQL a mano. In questi casi è possibile comunque poter riusare la connessione al database stabilita da Django. Questi casi dovrebbero essere limitati in casi in cui ogni eventuale input è validato e considerato sicuro, un caso d'uso ad esempio è per sistemi di analisi dati in cui su grosse moli le query efficienti fanno la differenza.

Possiamo aprire una shell di Django e provare il seguente codice:

from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("SELECT *  FROM corsi_corso WHERE titolo = %s", ["titolo"])
    rows = cursor.fetchall()
    for row in rows:
        print(rows)

fetchall() restituisce tutte le righe risultanti dalla precedente query sottoforma di tuple:

((1, 'titolo', 'descrizione', datetime.datetime(2021, 4, 10, 15, 16, 22, 524469), datetime.datetime(2021, 4, 10, 15, 16, 22, 524499), 1),)

Si rimanda alla documentazione sulle query SQL per vedere tutti i metodo messi a disposizione dalle istanze di cursor. L'API non è dissimile da quella che si trova usando direttamente i driver del database.

Usare più di un database

Integrarsi con altre applicazioni tramite l'utilizzo dello stesso database non è il modo preferibile per farlo, un cambio al database da una parte potrebbe spaccare un'applicazione dall'altra. Far esporre una API da un lato è preferibile. Detto questo può capitare di dover accedere ad altri database e poterlo fare comodamente ha certamente dei vantaggi.

Django permette di configurare più di un database molto facilmente, aggiungendo delle ulteriori chiavi rispetto a default, come ad esempio:

DATABASES = {
    'default': {
        'NAME': 'database',
        'ENGINE': 'django.db.backends.mysql',
        'USER': 'mysql',
        'PASSWORD': 'password'
    },
    'vecchio_database': {
        'NAME': 'vecchio_database',
        'ENGINE': 'django.db.backends.mysql',
        'USER': 'altroutente',
        'PASSWORD': 'altrapassword'
    }
}

Semplicemente configurando la connessione ad un database possiamo scrivere direttamente le nostre query usando quel database:

from django.db import connections

with connections["vecchio_database"].cursor() as cursor:
    ...

Usare modelli non gestiti da Django

In altri casi potrebbe far comodo comunque usare le interfaccie più di alto livello di Django come i modelli con tabelle che non vogliamo gestire dalla nostra applicazione.

Django offre il comando inspectdb che permette di generare automaticamente dei modelli facendo introspezione di un database. Ad esempio se avessimo una precedente versione della nostra applicazione per gestire il catalogo dei corsi potremmo creare una applicazione corsi_legacy e generare il file models.py col seguente comando:

python3 manage.py inspectdb --database vecchio_database > models.py

inspectdb potrebbe non riuscire a recuperare tutti i campi in modo fedele perciò sarò comunque necessario un processo manuale di verifica.

Per usare un database specifico per popolare un Queryset bisogna specificare il database usando il metodo using():

Corso.objects.using("vecchio_database").all()

Per i metodi delle istanze invece come save e delete è implicito perché verrà usato lo stesso database dal quale è stato recuperato. È comunque possibile passarlo esplicitamente:

corso = Corso.objects.using("vecchio_database").get(titolo="titolo")
corso.save(using="vecchio_database")
corso.delete(using="vecchio_database")

Esercizi

Consulta la documentazione sull'uso di molteplici database.

Leggi la documentazione sull'usare database non gestiti.

Consulta la documentazione di inspectdb per vedere le sue opzioni.

Autenticazione con LDAP

Usare LDAP per l'autenticazione ad un sito interno permette di configurare utenti e permessi in un solo posto.

L'applicazione che possiamo usare per integrare LDAP nel nostro progetto Django si chiama django-auth-ldap. django-auth-ldap dipende da python-ldap che a sua volta dipende dalle librerie OpenLDAP che dobbiamo installare nel nostro sistema.

In un sistema Debian / Ubuntu possiamo installare tutto quello che ci serve con questo comando:

sudo apt install build-essential python3-dev libldap2-dev libsasl2-dev

Quindi possiamo procedere ad installare i nostri pacchetti:

pip install python-ldap django-auth-ldap

Per usare esclusivamente l'autenticazione ed ereditare completamente i permessi da LDAP dobbiamo configurare nel nostro settings AUTHENTICATION_BACKENDS per usare esclusivamente il backend di autenticazione fornito django-auth-ldap:

AUTHENTICATION_BACKENDS = [
    'django_auth_ldap.backend.LDAPBackend',
]

Se invece vogliamo gestire i permessi individualmente usando il supporto di Django dobbiamo comunque mantenere il backend di autenticazione ModelBackend:

AUTHENTICATION_BACKENDS = [
    'django_auth_ldap.backend.LDAPBackend',
    'django.contrib.auth.backends.ModelBackend',
]

Per configurare l'integrazione con il server LDAP per prima cosa bisogna specificare l'indirizzo:

AUTH_LDAP_SERVER_URI = "ldap://ldap.example.com"

Quindi bisogna autenticarsi settando AUTH_LDAP_USER_SEARCH in questo modo diremo a Django di cercare gli utenti nella organisational unit (ou) users sotto il distinguished name example.com cercando il valore che useremo come username al login:

import ldap
from django_auth_ldap.config import LDAPSearch

AUTH_LDAP_USER_SEARCH = LDAPSearch(
    'ou=users,dc=example,dc=com',
    ldap.SCOPE_SUBTREE,
    '(uid=%(user)s)',
)

Discorso analogo vale per i gruppi configurabili tramite AUTH_LDAP_GROUP_SEARCH:

AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
    'ou=django,ou=groups,dc=example,dc=com',
    ldap.SCOPE_SUBTREE,
    '(objectClass=groupOfNames)',
)

Trovati gli utenti dobbiamo mapparli con i modelli già presenti in Django tramite AUTH_LDAP_USER_ATTR_MAP:

AUTH_LDAP_USER_ATTR_MAP = {
    'first_name': 'givenName',
    'last_name': 'sn',
    'email': 'mail',
}

In questo esempio stiamo mappando tramite AUTH_LDAP_USER_ATTR_MAP i campi del modello User first_name, last_name ed email rispettivamente sui campi LDAP givenName, sn e mail.

Infine dobbiamo mappare i gruppi con gli attributi dei nostri utenti tramite AUTH_LDAP_USER_FLAGS_BY_GROUP:

AUTH_LDAP_USER_FLAGS_BY_GROUP = {
    'is_active': 'cn=active,ou=django,ou=groups,dc=example,dc=com',
    'is_staff': 'cn=staff,ou=django,ou=groups,dc=example,dc=com',
    'is_superuser': 'cn=superuser,ou=django,ou=groups,dc=example,dc=com',
}

Con questa configurazione stiamo mappando l'oggetto con common name (cn) active sotto django.groups con il flag is_active, staff con il flag is_staff e superuser con il flag is_superuser.

Sono disponibili ovviamente ulteriori configurazioni e si rimanda alla documentazione ufficiale di django-auth-ldap.

Esercizi

Consulta la documentazione sulla gestione dei permessi.

Autenticazione e permessi

Come abbiamo visto esiste già in Django una applicazione per gestire gli utenti chiamata auth in django.contrib.

Ogni istanza di utente ha tre flag:

  • is_active, che serve per definire se un utente è attivo o meno. Quando non è flaggato l'utente non viene autenticato e quindi non può completare il login.
  • is_staff, serve per abilitare o meno ad un utente l'accesso al sito di admin.
  • is_superuser, abilita qualsiasi permesso senza assegnarli esplicitamente, un flag da usare con parsimonia.

Oltre agli utenti l'applicazione ci mette a disposizione altri due modelli interessati: Group per gestire gruppi di utenti e Permission per implementare un sistema di permessi.

I gruppi permetto di raggruppare gli utenti e ad ogni gruppo possono essere associati dei permessi. Associare i permessi ai gruppi e non direttamente ai singoli utenti permette di mantenere un progetto più ordinato.

I permessi sono legati ad una specifica azione da compiere su uno specifico modello, per ogni modello abbiamo 4 permessi: permesso di inserire una nuova istanza, permesso di cambiare una istanza, permesso di cancellare una istanza e permesso di visualizzare una istanza.

Prendiamo confidenza questi concetti tramite l'admin, prova a creare un utente staff ma non superuser e vedi come reagisce l'admin a seconda dei permessi che vengono assegnati.

Nel caso siano necessari dei permessi custom è possibile crearli tramite l'attributo Meta di un modello come descritto nella documentazione.

I permessi possono essere validati dalla nostra applicazione tramite il metodo has_perm del modello utente. Ad esempio usando il modello Corso della nostra applicazione corsi dovremmo usare:

  • user.has_perm('corsi.add_corso'), per controllare che un utente possa creare un Corso
  • user.has_perm('corsi.change_corso'), per controllare che un utente possa modificare un Corso
  • user.has_perm('corsi.delete_corso'), per controllare che un utente possa eliminare un Corso
  • user.has_perm('corsi.view_corso'), per controllare che un utente possa visualizzare un Corso

Controlli nelle viste

Per le nostre viste fatte a classi esistono un paio di mixin che possiamo usare per limitare comodamento l'accesso ad una vista solo agli utenti che rispettano alcuni requisiti.

Possiamo limitare una vista agli utenti che passano un controllo specifico usando UserPassesTestMixin ed implementando il metodo test_func.

Ad esempio possiamo limitare una vista solo agli utenti che sono attivi e che sono staff:

from django.contrib.auth.mixins import UserPassesTestMixin


class CorsoCreateView(UserPassesTestMixin, CreateView):
    model = Corso
    form_class = CorsoForm

    def test_func(self):
        utente = self.request.user
        return utente.is_active and utente.is_staff

Nel caso l'utente non sia loggato self.request.user è una istanza di AnonymousUser che offre la stessa interfaccia di un utente loggato

Allo stesso possiamo usare il mixin PermissionRequiredMixin per limitare l'accesso ad una vista solo agli utenti che hanno dei permessi attivi specificando l'attributo permission_required:

from django.contrib.auth.mixins import PermissionRequiredMixin


class CorsoCreateView(PermissionRequiredMixin, CreateView):
    model = Corso
    form_class = CorsoForm
    permission_required = ('corsi.add_corso',)

Usare un altro modello utente

auth permette una buona dose di personalizzazione permettendo anche di sostituire il modello usato per l'utente. Può essere una buona idea usare un modello specifico quando si fanno progetti nuovi e si hanno esigenze particolari, ad esempio richieste di performance estreme, per cui non vogliamo pagare il costo di una join SQL per recuperare dati accessori per l'utente. In questi casi esiste una ricca documentazione ufficiale da consultare.

Esercizi

Leggi la documentazione ufficiale del sistema di autenticazione.

Leggi la documentazione di UserPassesTestMixin.

Leggi la documentazione di PermissionRequiredMixin.

Validazione nei form

L'interfaccia dei form di Django offre diversi metodi per validare i dati inseriti in un form. Il primo metodo è quello di usare un validatore specifico su un singolo campo. Ipotizziamo di voler creare un form per implementare una ricerca e di voler validare che la query contenga un numero minimo di caratteri. Il nostro codice potrebbe assomigliare al seguente:

from django import forms
from django.core.exceptions import ValidationError


def almeno_tre_caratteri(value):
    if len(value) < 3:
        raise ValidationError("La query deve essere di almeno 3 caratteri")


class RicercaForm(forms.Form):
    query = forms.CharField(validators=[almeno_tre_caratteri])

Abbiamo creato un Form con un singolo campo query che viene validato dalla funzione almeno_tre_caratteri che in caso il valore non abbiamo sia lungo almeno 3 caratteri alza l'eccezione ValidationError con un messaggio di errore. Come possiamo notare il validatore non è altro che una semplice funzione che prende il valore del campo come parametro.

Per verificare che il nostro form ed il nostro validatore funzionino a dovere dobbiamo scrivere dei test come i seguenti:

from corsi.forms import RicercaForm, almeno_tre_caratteri


class RicercaFormTestCase(TestCase):
    def test_validatore_alza_eccezione_con_meno_di_3_caratteri(self):
        with self.assertRaisesMessage(ValidationError, "La query deve essere di almeno 3 caratteri"):
            almeno_tre_caratteri("ab")

    def test_validatore_non_alza_eccezione_con_almeno_3_caratteri(self):
        self.assertIsNone(almeno_tre_caratteri("abc"))

    def test_form_valida_campo_query(self):
        form = RicercaForm({"query": "abc"})
        self.assertTrue(form.is_valid())

    def test_form_restituisce_errore_con_query_minore_di_3_caratteri(self):
        form = RicercaForm({"query": "ab"})
        self.assertFalse(form.is_valid())

    def test_form_restituisce_errore_senza_dati(self):
        form = RicercaForm({})
        self.assertFalse(form.is_valid())

In questi stiamo testando che il validatore validi correttamente la stringa passata e in caso di errore restituisca il messaggio che ci aspettiamo. Validiamo invece che il form applichi il validatore e cambi il valore restituito dal metodo is_valid().

Django offre altri due metodo per validare un form: il metodo clean() del form e i metodi clean_<nomecampo>() per validare ogni singolo campo. A differenza del sistema col validatore questi metodi permettono di cambiare il contenuto del campo che stanno controllando.

Possiamo reimplementare il form precedente validando il singolo campo in questo modo:

class RicercaForm(forms.Form):
    query = forms.CharField()

    def clean_query(self):
        query = self.cleaned_data["query"]
        if len(query) < 3:
            raise ValidationError("La query deve essere di almeno 3 caratteri")
        return query

Possiamo notare che ora dobbiamo sempre restituire qualcosa che verrà salvato nella chiave query di cleaned_data al termine della validazione.

A differenza del metodo clean per il singolo campo, quello generico del form ha visione su tutti i campi del form e quindi è possibile validare un campo in relazione agli altri.

class RicercaForm(forms.Form):
    query = forms.CharField()

    def clean(self):
        super().clean()
        query = self.cleaned_data.get("query", "")
        if len(query) < 3:
            raise ValidationError("La query deve essere di almeno 3 caratteri")

Qui dobbiamo fare attenzione ad una cosa importante. A differenza dei due metodi precedenti in questo caso non possiamo dare per scontato che i campi dei form siano valorizzati. Infatti stiamo usando la stringa vuota "" come default nel caso che il campo query non sia definito. Per il metodo clean non è obbligatorio restituire il valore di cleaned_data se non abbiamo cambiato il contenuto da quello che ha settato la chiamata al metodo clean() di default.

Esercizi

Leggi la documentazione sui validatori e quale validatore avremmo potuto usare al posto di implementarne una nostra versione.

Leggi la documentazione ufficiale sulla validazione dei form.

Internazionalizzazione e Localizzazione

Internazionalizzazione e localizzazione su Django sono gestiti separatamente e governati rispettivamente dalla configurazione USE_I18N e USE_L10N nei settings. Entrambi sono abilitati per default.

Internazionalizzazione

Per abilitare la traduzione su una lingua diversa per ogni sessione utente è necessario abilitare il middleware LocaleMiddleware, che andremo ad aggiungere alla configurazione MIDDLEWARE nel file settings.py:

MIDDLEWARE = [
   ...
   'django.contrib.sessions.middleware.SessionMiddleware',
   'django.middleware.locale.LocaleMiddleware',
   'django.middleware.common.CommonMiddleware',
   ...
]

Questo middleware deve essere inserito tra quello che gestisce la sessione SessionMiddleware e CommonMiddleware. Il middleware si occuperà di settare nella request passata ad ogni vista la variable LANGUAGE_CODE contenente la lingua corrente per la sessione dell'utente.

Sempre in settings.py è possibile configurare la lingua di default del sistema settando LANGUAGE_CODE. Ad esempio se vogliamo usare l'italiano per default:

LANGUAGE_CODE = 'it-it'

Le lingue per cui abilitiamo la traduzione devono essere listate esplicitamente nella configurazione LANGUAGES:

from django.utils.translation import gettext_lazy as _

LANGUAGES = [
    ('it', _('Italian'),
    ('en', _('English'),
]

Per far cambiare la lingua agli utenti Django offre una vista già implementata chiamata set_language che richiede il codice della lingua passato in POST nella variabile language.

Per essere usata basta includere le seguenti urls nel nostro progetto, ad esempio sotto il percorso i18n:

urlpatterns = [
   ...
   path('i18n/', include('django.conf.urls.i18n')),
]

In questo caso la vista sarà disponibile al percorso /i18n/setlang/. Si rimanda alla documentazione di set_language per maggiori dettagli.

Lo stato delle traduzioni per le varie lingue è disponibile su transifex.

Traduzioni nelle viste

Il sistema di traduzioni di Django è un wrapper sopra alle funzioni di GNU gettext.

La funzione base per tradurre una stringa è gettext che è consuetudine importare come _:

from django.http import HttpResponse
from django.utils.translation import gettext as _
from django.views import View


class HomepageView(View):
    def get(self, request):
        return HttpResponse(_("This is the homepage"))

Traduzioni lazy

Le stringhe possono essere tradotte in modo lazy cioè tradotte nel momento specifico in cui sono renderizzate e non quando sono definite. Queste tipo di traduzioni sono necessarie in alcuni punti come negli attributi help_text e verbose_name dei campi dei modelli, negli attributi verbose_name e verbose_name_plural nella classe Meta dei modelli e nel file settings.py.

Le traduzioni di tipo lazy si fanno usando la funzione gettext_lazy:

from django.db import models
from django.utils.translation import gettext_lazy as _


class Corso(models.Model):
    titolo = models.CharField(help_text=_('Titolo'))

Template

Le traduzioni nei template vengono fatte tramite il tag translate che opera indistintamente sia su stringhe costanti che su variabili:

<h1>{% translate "Il titolo" %}</h1>
<h2>{% translate sottotitolo %}</h2>

Generare i file delle traduzioni

I file delle traduzioni sono cercati per default in directory chiamate locale all'interno di ogni applicazione configurata e in ogni directory configurata in LOCALE_PATHS.

Le directory locale all'interno delle nostri applicazioni e quelle configurate in LOCALE_PATHS devono essere create manualmente. È buona cosa creare una directory locale per ogni applicazione che ha delle stringhe traducibili ed una generale per le stringhe che sono presenti nei file di progetto o nei templates condivisi.

Per estrarre le stringhe da tradurre per una singola lingua bisogna eseguire il comando makemessages:

django-admin makemessages -l it

In questo caso abbiamo estratto le stringhe da tradurre per la lingua italiana it. Le stringhe vengono estratte in file chiamati django.po all'interno di ogni directory it presente all'interno di ogni directory locale globale o della singola applicazione.

Una volta tradotti i file django.po possono essere compilati in file django.mo tramite il comando:

django-admin compilemessages

Localizzazione

Il sistema di localizzazione di Django permette di formattare date, orari e numeri nei template. Quando è attivato questi tipidi dato vengono formattati a seconda del linguaggio configurato nella sessione. La formattazione che inserisce il separatore per le migliaia va attivato separatamente tramite la configurazione USE_THOUSAND_SEPARATOR.

Le localizzazioni sono implementate in un file formats.py all'interno di ogni directory di lingua.

Nei template singoli valori possono essere localizzati o non localizzati usando i filtri localize o unlocalize:

{% load l10n %}

{{ value|localize }}

{{ value|unlocalize }}

Esercizi

Leggi la documentazione ufficiale sulla internazionalizzazione.

Leggi la documentazione su come settare il linguaggio preferito dagli utenti.

Consulta la documentazione sulle traduzioni lazy.

Consulta la documentazione dei comandi makemessages e compilemessages.

Leggi la documentazione ufficiale sulla localizzazione.

Menzioni

In questa sezione tratteremo brevemente degli argomenti che possono risultare utili e che eventualmente saranno promossi in una sezione separata.

Funzioni di aggregazione

Django permette di annotare ed aggregare valori nei QuerySet usando delle espressioni tramite i metodi annotate ed aggregate. Si consiglia la lettura della documentazione ufficiale sulle aggregazioni. Questi strumenti permettono di demandare più lavoro al database sfruttandone le funzioni più avanzate.

Query N+1

L'ORM di Django si presta al creare il problema delle query N+1, dove quando si itera sopra ad un QuerySet vengono fatte altre query per ogni occorrenza. Per sopperire a questo si possono usare rispettivamente select_related per i campi ForeignKey o OneToOneField e prefetch_related per i ManyToManyField.

Modelli astratti

Ci sono dei casi in cui potrebbe far comodo ereditare classi che estendono models.Model per riusarne i campi definiti. Django permette questo pattern impostando nella classe Meta l'attributo abstract.

Indici e vincoli

Django permette di aggiungere facilmente degli indici ai nostri campi tramite l'attributo db_index. Per avere più controllo sugli indici è possibile configurarli tramite indexes nella classe Meta. Sempre nella classe Meta si possono configurare dei vincoli sui campi tramite constraints.

Viste per gestire gli errori

Django offre per default delle viste che gestiscono i maggiori casi di errore ossia 403, 404 e 500. Le viste per gli errori 403 e 404 possono essere richiamate rispettivamente tramite le eccezioni PermissionDenied e Http404, mentre la vista per gli errori 500 viene richiamata automaticamente in caso di errori a runtime non gestiti. Si rimanda alla documentazione delle viste di errore e alla loro customizzazione.

Paginazione

Django comprende un sistema di paginazione già integrato nelle viste a classi.

Storage dei file in cloud

Esistono applicazioni di terze parti per usare sistemi di storage diversi dal filesystem locale per salvare i file caricati dagli utenti come ad esempio django-storages che permette di salvare i file su sistemi di object-storage di diversi provider.

Comandi

Possiamo estendere il nostro progetto con ulteriori comandi rispetto a quelli forniti da Django implementando delle classi come descrive la documentazione per scrivere i propri comandi.

Azioni per l'admin

Possiamo inoltre scrivere per interagire con i nostri modelli tramite l'interfaccia di admin delle azioni.

Dei casi d'uso per le azioni potrebbe essere aggiornare qualche flag od effetture qualche ricalcolo su dei modelli specifici usando le feature dell'admin come i filtri e la ricerca per aiutarci.

Tool utili allo sviluppo

Un paio di applicazioni utili in modalità sviluppo che richiedono la configurazione DEBUG, e quindi non devono essere abilitati su istanze di produzione, sono Django Debug Toolbar e Django Silk. Django Debug Toolbar offre informazioni contestuali nella visualizzazione di una pagina, mentre Django Silk salva tutte le richieste fatte e permette di ispezionarle in un secondo momento.