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.