7.3. Quiz ORM

Realizacja aplikacji internetowej Quiz w oparciu o framework Flask i bazę danych SQLite zarządzaną systemem ORM Peewee lub SQLAlchemy.

7.3.1. Wymagania

Dobre zrozumienie omawianych tu zagadnień wymaga przyswojenia podstaw Pythona omówionych w scenariuszu “Python w przykładach” (tematy 2-6), obsługi bazy danych przedstawionej w scenariuszu “Bazy danych w Pythonie” oraz scenariusza wprowadzającego do użycia frameworka Flask w aplikacjach internetowych pt. “Quiz”. Zalecamy również zapoznanie się ze scenariuszem “ToDo”, który ilustruje użycie bazy danych obsługiwanej za pomocą wbudowanego modułu sqlite3 z aplikacją internetową.

Wykorzystywane biblioteki instalujemy przy użyciu instalatora pip:

~$ sudo pip install peewee sqlalchemy flask-sqlalchemy

7.3.2. Modularyzacja

Scenariusze “Quiz” i “ToDo” pokazują możliwość umieszczenia całego kodu aplikacji obsługiwanej przez Flaska w jednym pliku. O ile dla celów szkoleniowych jest to dobre rozwiązanie, o tyle w praktycznych realizacjach wygodniej logicznie rozdzielić poszczególne części aplikacji i umieścić je w osobnych plikach, których nazwy określają ich przeznaczenie. Podejście takie usprawnia rozwijanie aplikacji, ale również ułatwia poznawanie bardziej rozbudowanych systemów, takich jak Django, przedstawione w scenariuszu “Czat”.

Tak więc kod rozmieścimy następująco:

  • app.py – konfiguracja aplikacji Flaska i obiektu służącego do łączenia się z bazą;
  • models.py – klasy opisujące tabele, pola i relacje w bazie;
  • views.py – widoki obsługujące udostępnione użytkownikowi akcje, typu “rozwiąż quiz”, “dodaj pytanie”, “edytuj pytania” itp.
  • main.py – główny plik naszej aplikacji wiążący wszystkie powyższe, odpowiada za utworzenie tabel w bazie, wypełnienie ich danymi początkowymi i uruchomienie aplikacji, czyli serwera www;
  • dane.py – moduł opcjonalny, którego zadaniem jest odczytanie wstępnych danych z pliku csv i dodanie ich do bazy.

Wszystkie powyższe pliki muszą znajdować się w katalogu aplikacji quiz2. W podkatalogach templates umieścimy wszystkie szablony, czyli pliki z rozszerzeniem html, arkusz stylów o nazwie style.css znajdzie się w podkatalogu static. Potrzebną strukturę katalogów można utworzyć poleceniami:

~$ mkdir quiz2
~$ cd quiz2
~$ mkdir templates; mkdir static
~$ touch app.py

Komendę z ostatniej linii, która tworzy pusty plik o podanej nazwie, wydajemy w miarę rozbudowywania aplikacji. Można oczywiście korzystać z wybranego edytora.

7.3.3. Aplikacja i baza

Peewee. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# -*- coding: utf-8 -*-
# quiz_pw/app.py

from flask import Flask, g
from peewee import *

app = Flask(__name__)

# konfiguracja aplikacji, m.in. klucz do obsługi sesji HTTP wymaganej
# przez funkcję flash
app.config.update(dict(
    SECRET_KEY='bardzosekretnawartosc',
    TYTUL='Quiz 2 Peewee'
))

# tworzymy instancję bazy używanej przez modele
baza = SqliteDatabase('quiz.db')


@app.before_request
def before_request():
    g.db = baza
    g.db.connect()


@app.after_request
def after_request(response):
    g.db.close()
    return response
SQLAlchemy. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# -*- coding: utf-8 -*-
# quiz_sa/app.py

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)

# konfiguracja aplikacji, m.in. klucz do obsługi sesji HTTP wymaganej
# przez funkcję flash
app.config.update(dict(
    SECRET_KEY='bardzosekretnawartosc',
    SQLALCHEMY_DATABASE_URI='sqlite:///quiz.db',
    SQLALCHEMY_TRACK_MODIFICATIONS=False,
    TYTUL='Quiz 2 SQLAlchemy'
))

# tworzymy instancję bazy używanej przez modele
baza = SQLAlchemy(app)

Moduł app.py, jak wskazuje sama nazwa, służy zainicjowaniu aplikacji Flaska (app = Flask(__name__)). Jej ustawienia przechowywane są w słowniku .config. Oprócz klucza używanego do obsługi sesji (SECRET_KEY), a także nazwy wykorzystywanej w szablonach (TYTUL), w przypadku SQLAlchemy definiujemy tu nazwę pliku bazy danych (SQLALCHEMY_DATABASE_URI='sqlite:///quiz.db') i wyłączamy śledzenie modyfikacji (SQLALCHEMY_TRACK_MODIFICATIONS=False). Następnie tworzymy instancję obiektu reprezentującego bazę.

Peewee wykorzystuje specjalną zmienną g, w której możemy przechowywać różne zasoby składające się na kontekst aplikacji, np. instancję bazy. Tworzymy ją przekazując konstruktorowi nazwę pliku (SqliteDatabase('quiz.db')). Następnie przy użyciu odpowiednich dekoratorów Flaska definiujemy funkcje otwierające i zamykające połączenie w ramach każdego cyklu żądanie-odpowiedź, co stanowi specyficzny wymóg bazy SQLite.

SQLAlchemy będziemy obsługiwać za pomocą rozszerzenia flask_sqlalchemy, które ułatwia używanie tego systemu ORM. Dzięki niemu tworzymy instancję bazy powiązaną z konkretną aplikacją Flaska dzięki prostemu wywołaniu odpowiedniego konstruktora (baza = SQLAlchemy(app)).

7.3.4. Modele

Peewee. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# -*- coding: utf-8 -*-
# quiz_pw/models.py

from app import baza
from peewee import *


class BaseModel(Model):

    class Meta:
        database = baza


class Pytanie(BaseModel):
    pytanie = CharField(unique=True)
    odpok = CharField()


class Odpowiedz(BaseModel):
    pnr = ForeignKeyField(
        Pytanie, related_name='odpowiedzi', on_delete='CASCADE')
    odpowiedz = CharField()
SQLAlchemy. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# -*- coding: utf-8 -*-
# quiz_sa/models.py

from app import baza


class Pytanie(baza.Model):
    id = baza.Column(baza.Integer, primary_key=True)
    pytanie = baza.Column(baza.String(255), unique=True)
    odpok = baza.Column(baza.String(100))
    odpowiedzi = baza.relationship(
        'Odpowiedz', backref=baza.backref('pytanie'),
        cascade="all, delete, delete-orphan")


class Odpowiedz(baza.Model):
    id = baza.Column(baza.Integer, primary_key=True)
    pnr = baza.Column(baza.Integer, baza.ForeignKey('pytanie.id'))
    odpowiedz = baza.Column(baza.String(100))

Modele to miejsce, w którym opisujemy strukturę naszej bazy danych, a więc definiujemy klasy – odpowiadające tabelom i ich właściwości - odpowiadające kolumnom. Jak widać, wykorzystamy tabelę Pytanie, zawierającą treść pytania i poprawną odpowiedź, oraz tabelę Odpowiedź, która przechowywać będzie wszystkie możliwe odpowiedzi. Relację jeden-do-wielu między tabelami tworzyć będzie pole pnr, czyli klucz obcy (ForeignKey), przechowujący identyfikator pytania. W obu systemach nieco inaczej definiujemy to powiązanie, w Peewee podajemy nazwę klasy (Pytanie), w SQLAlchemy nazwę konkretnego pola (pytani.id). W obu przypadkach inaczej też określamy relacje zwrotne w postaci pola odpowiedzi, za pomocą którego w obiekcie Pytanie będziemy mieli dostęp do przypisanych mu odpowiedzi.

Na uwagę zasługują atrybuty dodatkowe, dzięki którym po usunięciu pytania, usunięte również zostaną wszystkie przypisane mu odpowiedzi. W Peewee podajemy: on_delete = 'CASCADE'; w SQLAlchemy: cascade="all, delete, delete-orphan".

Warto zauważyć również, że w SQLAlchemy dzięki rozszerzeniu flask.ext.sqlalchemy jedyny import, którego potrzebujemy, to obiekt baza, który udostępnia wszystkie klasy i metody SQLAlchemy. Druga rzecz to miejsce, w którym określamy relację zwrotną. Inaczej niż w Peewee robimy to w klasie Pytanie.

7.3.5. Widoki

Przypomnijmy, że widoki to funkcje obsługujące przypisane im adresy url. Najczęściej po wykonaniu określonych operacji zawierają również wywołanie szablonu html, który uzupełniony o ewentualne dane zostaje odesłany użytkownikowi. Zawartość tych funkcji jest w dużej mierze niezależna od obsługi bazy, dlatego poniżej prezentować będziemy kompletny kod dla Peewee, a potrzebne zmiany dla SQLAlchemy będziemy wskazywać w komentarzu lub przywoływać we fragmentach. Warto również zaznaczyć, że wykorzystywane szablony dla obu systemów są takie same.

7.3.5.1. Strona główna i szablony

Widok obsługujący stronę główną w obu przypadkach jest prawie taki sam, w Peewee linia from app import baza nie jest potrzebna:

Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# -*- coding: utf-8 -*-
# quiz_sa/views.py

from flask import render_template, request, redirect, url_for, flash

from app import app
from app import baza
from models import Pytanie, Odpowiedz


@app.route('/')
def index():
    return render_template('index.html')

Zadaniem funkcji index() jest tylko wywołanie renderowania szablonu index.html, który zostanie zwrócony użytkownikowi. W omówionych do tej pory scenariuszach aplikacji internetowych (Quiz, ToDo) opartych na Flasku każdy szablon zawierał kompletny kod strony. W praktyce jednak spora część kodu HTML powtarza się na każdej stronie w ramach danego serwisu. W związku z tym nasze szablony będą oparte o wzorzec zawierający stałe elementy i bloki oznaczające fragmenty, które będzie można dostosować do danego widoku. Wzorzec umieszczamy w katalogu templates pod nazwą szkielet.html.

Szablon szkielet.html. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!doctype html>
<!-- quiz2pw/templates/szkielet.html -->
<html>
    <head>
        <title>{% block tytul %}{% endblock %} &#8211; {{ config.TYTUL }}</title>
        <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='style.css') }}">
    </head>
    <body>
        <h1>{% block h1 %}{% endblock %}</h1>
        <div id="menu" class="cb">
        <ul>
            <li><a href="{{ url_for('index') }}">Strona główna</a></li>
            <li><a href="{{ url_for('quiz') }}">Rozwiąż quiz</a></li>
            <li><a href="{{ url_for('dodaj') }}">Dodaj pytanie</a></li>            
            <li><a href="{{ url_for('edytuj') }}">Edytuj pytania</a></li>
        </ul>
        </div>
        <div id="komunikaty" class="cb">
            {% for kategoria, komunikat in get_flashed_messages(with_categories=true) %}
                <span class="{{ kategoria }}">{{ komunikat }}</span>
            {% endfor %}
        </div>
        <div id="tresc" class="cb">
        {% block tresc %}
        {% endblock %}
        </div>
    </body>
</html>

Przypomnijmy i uzupełnijmy składnię. Instrukcje sterujące otoczone znacznikami {% %} wymagają otwarcia i zamknięcia, np.: {% for %} {% endfor %}. Nowy znacznik {% block nazwa_bloku %} pozwala definiować nazwane miejsca, w których szablony dziedziczące mogą wstawiać swój kod. Jeżeli chcemy umieścić w kodzie konkretne wartości używamy znaczników {{ zmienna }}.

We wzorcu szablonów zawarliśmy więc elementy stałe, takie jak dołączane style css w nagłówku strony, menu nawigacyjne wyświetlane na każdej stronie (<div id="menu" class="cb">...</div>) oraz wyświetlanie komunikatów (<div id="komunikaty" class="cb">). W każdym szablonie zwracanym przez zdefiniowane widoki możemy natomiast zmienić tytuł strony ({% block tytul %}{% endblock %}), nagłówek strony ({% block h1 %}{% endblock %}) i przede wszystkim treść ({% block tresc %}{% endblock %}). Tak właśnie robimy w szablonie index.html:

Szablon index.html. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- quiz2pw/templates/index.html -->
{% extends "szkielet.html" %}
{% block tytul %}Strona główna{% endblock%}
{% block h1 %}Quiz 2 &#8211; Peewee{% endblock%}
{% block tresc %}
    <p>
        Przykład aplikacji internetowej wykorzystującej framework Flask
        do tworzenia m.in. serwisów WWW oraz system
        <strong>ORM <a href="http://peewee.readthedocs.org/en/latest/">Peewee</a></strong> do obsługi
        bazy danych.
    </p>
    <p>
        Pokazujemy, jak:
        <ul>
            <li>utworzyć model bazy i samą bazę</li>
            <li>obsługiwać bazę z poziomu aplikacji www</li>
            <li>używać szablonów do prezentacji treści</li>
            <li>i wiele innych rzeczy...</li>
        </ul>
    </p>
{% endblock %}

Każdy szablon dziedziczący z wzorca musi zawierać znacznik {% extends "szkielet.html" %}, a jeżeli coś zmienia, umieszcza odpowiednią treść w znaczniku typu {% block tresc %} treść {% endblock %}.

Dla porządku spójrzmy jeszcze na zawartość pliku style.css zapisanego w katalogu static i określającego wygląd naszej aplikacji.

Arkusz stylów style.css. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* quiz2pw/static/style.css */

body {
    margin-top: 2em;
    font: 1em/1.3em arial,tahoma,sans-serif;
    background-color: #E6E6FA;
    color: #000;
}
#menu { padding-bottom: 0.5em; }
#menu li { float: left; margin-left: 2em; }
#komunikaty {
    width: 80%;
    margin: 0.5em 2em 0 2em;
    padding: 1em;
    font-family: verdana,sans-serif;
    border-radius: 5px;
    background-color: #cecece;
}
#tresc { width: 80%; margin: 0.5em 2em 0 2em; }
h1, p { margin: 0 0 1em 2em; }
.add-form { margin-left: 2em; }
ol { text-align: left; }
form { display: inline-block; margin-bottom: 0;}
input[type=text] { width: 300px; margin-bottom: 0.5em; }
input:focus {
    border-color: light-blue;
    border-radius: 5px;
}
li { margin-bottom: 5px; }
button {
    margin-top: 0.5em;
    padding: 0;
    cursor: pointer;
    font-size: 1em;
    background: white;
    border: 1px solid gray;
    border-radius: 3px;
    color: blue;
}
span.blad { color: red; }
span.sukces { color: green; }
span.kom { color: blue; }
.cb { clear: both; }
.fb { font-weight: bold; }

7.3.6. Powiązanie modułów

Po zdefiniowaniu aplikacji, bazy, modelu, widoków i wykorzystywanych przez nie szablonów, trzeba wszystkie moduły połączyć w całość. Posłuży nam do tego plik main.py:

Peewee. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# -*- coding: utf-8 -*-
# quiz_pw/main.py

from app import app, baza
from models import *
from views import *
from dane import *
import os

if __name__ == '__main__':
    if not os.path.exists('quiz.db'):
        baza.create_tables([Pytanie, Odpowiedz], True)  # tworzymy tabele
        dodaj_pytania(pobierz_dane('pytania.csv'))
    app.run(debug=True)

Żeby zrozumieć rolę tego modułu, wystarczy prześledzić źródła importów, które w Pythonie odpowiadają nazwom plików. Tak więc z pliku (modułu) app.py importujemy instancję aplikacji i bazy, z models.py klasy opisujące schemat bazy, a z views.py zdefiniowane widoki. W podanym kodzie najważniejsze jest polecenie tworzące bazę i tabele: baza.create_tables([Pytanie, Odpowiedz],True); w SQLAlchemy trzeba zastąpić je wywołaniem baza.create_all(). Zostanie ono wykonane, o ile na dysku nie istnieje już plik bazy quiz.db.

Ostatnie polecenie app.run(debug=True) ma uruchomić naszą aplikację w trybie debugowania. Czas więc uruchomić nasz testowy serwer:

~/quiz2$ python main.py
../../_images/quiz2_1.png

Po wpisaniu w przeglądarce adresu 127.0.0.1:5000 powinniśmy zobaczyć:

../../_images/quiz2_2.png

7.3.7. Widoki CRUD

Skrót CRUD (Create (tworzenie), Read (odczyt), Update (aktualizacja), Delete (usuwanie)) oznacza, jak wyjaśniono, podstawowe operacje wykonywane na bazie danych.

7.3.7.1. Dane początkowe

Moduł dane.py:

SQLAlchemy. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# -*- coding: utf-8 -*-
# quiz_sa/dane.py

from app import baza
from models import Pytanie, Odpowiedz

import os


def pobierz_dane(plikcsv):
    """Funkcja zwraca tuplę tupli zawierających dane pobrane z pliku csv."""
    dane = []
    if os.path.isfile(plikcsv):
        with open(plikcsv, "r") as sCsv:
            for line in sCsv:
                line = line.replace("\n", "")  # usuwamy znaki końca linii
                line = line.decode("utf-8")  # format kodowania znaków
                dane.append(tuple(line.split("#")))
    else:
        print "Plik z danymi", plikcsv, "nie istnieje!"

    return tuple(dane)

W Peewee linia from app import baza nie jest potrzebna.

Plik z danymi:

Plik pytania.csv. Kod nr
1
2
3
Stolica Hiszpani, to:#Madryt, Warszawa, Barcelona#Madryt
Objętość sześcianu o boku 6 cm, wynosi:#36, 216, 18#216
Symbol pierwiastka Helu, to:#Fe, H, He#He

Pierwsza funkcja pobierz_dane('pytania.csv') odczytuje z podanego pliku kolejne linie zawierające pytanie, odpowiedzi i odpowiedź prawidłową oddzielone znakiem “#”. Z odczytanych linii usuwamy znaki końca linii, następnie ustawiamy kodowanie znaków, a na koniec rozbijamy je na trzy elementy (line.split("#")), z których tworzymy tuple i dodajemy ją do listy dane.append(tuple(...)). Na koniec listę tupli zwracamy jako tuplę, która trafia do wywołania drugiej funkcji dodaj_pytania().

Peewee. Kod nr
24
25
26
27
28
29
30
31
32
33
def dodaj_pytania(dane):
    """Funkcja dodaje pytania i odpowiedzi przekazane w tupli do bazy."""
    for pytanie, odpowiedzi, odpok in dane:
        pyt = Pytanie(pytanie=pytanie, odpok=odpok)
        pyt.save()
        for o in odpowiedzi.split(","):
            odp = Odpowiedz(pnr=pyt.id, odpowiedz=o.strip())
            odp.save()

    print "Dodano przykładowe pytania"
SQLAlchemy. Kod nr
25
26
27
28
29
30
31
32
33
34
35
36
def dodaj_pytania(dane):
    """Funkcja dodaje pytania i odpowiedzi przekazane w tupli do bazy."""
    for pytanie, odpowiedzi, odpok in dane:
        pyt = Pytanie(pytanie=pytanie, odpok=odpok)
        baza.session.add(pyt)
        baza.session.commit()
        for o in odpowiedzi.split(","):
            odp = Odpowiedz(pnr=pyt.id, odpowiedz=o.strip())
            baza.session.add(odp)
        baza.session.commit()

    print "Dodano przykładowe pytania"

Pętla for pytanie,odpowiedzi,odpok in dane: do oddzielonych przecinkami zmiennych odczytuje z przekazanych tupli kolejne dane. Następnie tworzymy obiekty reprezentujące rekordy w tablicy pytanie (pyt = Pytanie(pytanie = pytanie, odpok = odpok)) i wywołujemy odpowiednie dla danego ORM-u polecenia zapisujące je w bazie. Podobnie postępujemy w pętli wewnętrznej, przy czym tworząc obiekty odpowiedzi wykorzystujemy identyfikatory zapisanych wcześniej pytań (odp = Odpowiedz(pnr = pyt.id, odpowiedz = o.strip())).

7.3.7.2. Odczyt

Zaczniemy od widoku wyświetlającego pobrane z bazy dane w formie quizu i sprawdzającego udzielone przez użytkownika odpowiedzi.

Peewee. Kod nr
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@app.route('/quiz', methods=['GET', 'POST'])
def quiz():
    # POST, sprawdź odpowiedzi
    if request.method == 'POST':
        wynik = 0  # liczba poprawnych odpowiedzi
        # odczytujemy słownik z odpowiedziami
        for pid, odp in request.form.items():
            # pobieramy z bazy poprawną odpowiedź
            odpok = Pytanie.select(Pytanie.odpok).where(
                Pytanie.id == int(pid)).scalar()
            if odp == odpok:  # porównujemy odpowiedzi
                wynik += 1  # zwiększamy wynik
        # przygotowujemy informacje o wyniku
        flash(u'Liczba poprawnych odpowiedzi, to: {0}'.format(wynik), 'sukces')
        return redirect(url_for('index'))

    # GET, wyświetl pytania
    pytania = Pytanie().select().annotate(Odpowiedz)
    if not pytania.count():
        flash(u'Brak pytań w bazie.', 'kom')
        return redirect(url_for('index'))

    return render_template('quiz.html', pytania=pytania)

Wyświetlenie pytań wymaga odczytania ich wraz z możliwymi odpowiedziami z bazy. W Peewee korzystamy z kodu: Pytanie().select().annotate(Odpowiedz), w SQLAlchemy: Pytanie.query.join(Odpowiedz) (metoda .join() zwiększa efektywność, bo wymusza pobranie możliwych odpowiedzi w jednym zapytaniu). Po sprawdzeniu, czy mamy jakiekolwiek pytania za pomocą metody .count(), zwracamy użytkownikowi szablon quiz.html, któremu przekazujemy w zmiennej pytania dane w odpowiedniej formie. W SQLALchemy korzystamy z metody .all() zwracającej pasujące rekordy jako listę.

Szablon quiz.html – oparty na omówionym wcześniej wzorcu – wyświetla pytania i możliwe odpowiedzi jako pola opcji typu radio button:

Szablon quiz.html. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- quiz2pw/templates/quiz.html -->
{% extends "szkielet.html" %}
{% block tytul %}Pytania{% endblock%}
{% block h1 %}Quiz 2 &#8211; pytania{% endblock%}
{% block tresc %}
    <p class="fb">
        Odpowiedz na pytania:
    </p>
    <!-- formularz z quizem -->
    <form method="POST">
        <!-- pętla odczytująca kolejne pytania z listy -->
        {% for p in pytania %}
            <p>
                <!-- wypisujemy pytanie -->
                {{ p.pytanie }}
                <br>
                <!-- pętla odczytująca możliwe odpowiedzi dla danego pytania -->
                {% for o in p.odpowiedzi %}
                    <label>
                        <!-- odpowiedź wyświetlamy jako pole radio button -->
                        <input type="radio" value="{{ o.odpowiedz }}" name="{{ p.id }}">
                        {{ o.odpowiedz }}
                    </label>
                    <br>
                {% endfor %}
            </p>
        {% endfor %}

        <!-- przycisk wysyłający wypełniony formularz -->
        <button type="submit">Sprawdź odpowiedzi</button>
    </form>
{% endblock %}

Użytkownik po wybraniu odpowiedzi naciska przycisk Sprawdź... i przesyła do naszego widoku dane w żądaniu typu POST. W funkcji quiz() uwzględniamy taką sytuację i w pętli for pid, odp in request.form.items(): odczytujemy identyfikator pytania i udzieloną odpowiedź. Następnie pobieramy odpowiedź prawidłową w Peewee za pomocą kodu odpok = Pytanie.select(Pytanie.odpok).where(Pytanie.id == int(pid)).scalar(), a w SQLALchemy odpok = baza.session.query(Pytanie.odpok).filter(Pytanie.id == int(pid)).scalar(). W obu przypadkach metody .scalar() zwracają pojedyncze wartości, które porównujemy z odpowiedziami użytkownika (if odp == odpok:) i w przypadku poprawności zwiększamy wynik.

../../_images/quiz2_3.png

7.3.7.3. Dodawanie i aktualizacja

Możliwość dodawania nowych pytań i odpowiedzi wymaga stworzenia nowego widoku powiązanego z określonym adresem url, jak i szablonu, który wyświetli użytkownikowi właściwy formularz. Na początku zajmiemy się właśnie nim.

Szablon dodaj.html. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!-- quiz2pw/templates/dodaj.html -->
{% extends "szkielet.html" %}
{% block tytul %}Dodawanie{% endblock%}
{% block h1 %}Quiz 2 &#8211; dodawanie pytań{% endblock%}
{% block tresc %}
    <form method="POST" class="add-form" action="{{ url_for('dodaj') }}">
        <label>Wpisz pytanie:</label><br />
        {% if pytanie %}
            <!-- wstawiamy id pytania -->
            <input type="hidden" name="id" value="{{ pytanie.id }}" /><br />
            <input type="text" name="pytanie" value="{{ pytanie.pytanie }}" /><br />
        {% else %}
            <input type="text" name="pytanie" value="" /><br />
        {% endif %}
        <label>Podaj odpowiedzi:</label><br />
        <ol>
        {% if pytanie %}
            {% for o in pytanie.odpowiedzi %}
                <li><input type="text" name="odp[]" value="{{ o.odpowiedz }}" /></li>
            {% endfor %}
        {% else %}
            <li><input type="text" name="odp[]" value="" /></li>
            <li><input type="text" name="odp[]" value="" /></li>
            <li><input type="text" name="odp[]" value="" /></li>
        {% endif %}
        </ol>

        <label>Podaj numer poprawnej odpowiedzi:</label><br />
        {% if pytanie %}
            {% for o in pytanie.odpowiedzi %}
                {% if o.odpowiedz == pytanie.odpok %}
                <input type="text" name="odpok" value="{{ loop.index }}" /><br />
                {% endif %}
            {% endfor %}
        {% else %}
            <input type="text" name="odpok" value="" /><br />
        {% endif %}
        <button type="submit">Zapisz pytanie</button>
    </form>
{% endblock %}

Powyższy kod umieszczamy w pliku dodaj.html w katalogu szablonów, czyli templates. Jak widać najważniejszym elementem jest tu formularz. Zawiera on pola tekstowe przeznaczone na pytanie, trzy odpowiedzi i numer odpowiedzi poprawnej. Takiego formularza możemy użyć zarówno do dodawania nowych, jak i edycji istniejących już pytań. Jedyna różnica będzie taka, że przy edycji musimy w formularzu wyświetlić dane wybranego pytania. Dlatego w kodzie szablonu stosujemy instrukcję warunkową {% if pytanie %}, która decyduje o tym, czy wyświetlamy puste pola, czy wypełniamy je przekazanymi danymi. W tym ostatnim przypadku umieszczamy w formularzu dodatkowe ukryte pole, w którym zapisujemy id edytowanego pytania.

Załóżmy, że użytkownik wpisał lub zmienił pytanie i nacisnął przycisk typu submit, czyli wysłał dane do serwera. Co dzieje się dalej? Takie żądanie POST trafi do widoku dodaj(), co określone zostało w atrybucie formularza: action="{{ url_for('dodaj') }}". Zobaczmy, jak wygląda ten widok:

Peewee. Kod nr
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@app.route('/dodaj', methods=['GET', 'POST'])
def dodaj():
    error = []
    # POST, zapisz pytanie
    if request.method == 'POST':
        # sprawdzanie poprawności przesłanych danych
        if len(request.form['pytanie']) == 0:
            error.append(u'Błąd: pytanie nie może być puste!')
        odpowiedzi = list(request.form.getlist('odp[]'))
        for odp in odpowiedzi:
            if len(odp) == 0:
                error.append(u'Odpowiedź nie może być pusta!')
        if len(request.form['odpok']) == 0:
            error.append(u'Brak numeru poprawnej odpowiedzi!')
        elif int(request.form['odpok']) > len(odpowiedzi):
            error.append(u'Błędny numer poprawnej odpowiedzi!')

        if not error:  # jeżeli nie ma błędów dodajemy pytanie
            pytanie = request.form['pytanie'].strip()
            odpok = odpowiedzi[(int(request.form['odpok']) - 1)]
            try:
                if request.form['id']:  # aktualizujemy pytanie
                    p = Pytanie.select(Pytanie, Odpowiedz).join(Odpowiedz).\
                        where(Pytanie.id == int(request.form['id'])).get()
                    p.pytanie = pytanie.strip()
                    p.odpok = odpok.strip()
                    p.save()
                    for i, o in enumerate(list(p.odpowiedzi)):
                        o.odpowiedz = odpowiedzi[i].strip()
                        o.save()
                    flash(u'Zmieniono pytanie:', 'sukces')
            except KeyError:  # dodajemy nowe pytanie, brak id pytania!
                p = Pytanie(pytanie=pytanie.strip(), odpok=odpok.strip())
                p.save()
                for odp in odpowiedzi:
                    o = Odpowiedz(pnr=p, odpowiedz=odp.strip())
                    o.save()
                flash(u'Dodano pytanie:', 'sukces')

            flash("\n" + pytanie + " " + odpok.strip() +
                  " (" + ", ".join(odpowiedzi) + ")", 'kom')
            return redirect(url_for('index'))
        else:
            for e in error:
                flash(e, 'blad')

    # GET, wyświetl formularz
    return render_template('dodaj.html')
SQLAlchemy. Kod nr
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
            try:
                if request.form['id']:  # aktualizujemy pytanie
                    p = Pytanie.query.get(request.form['id'])
                    p.pytanie = pytanie.strip()
                    p.odpok = odpok.strip()
                    for i, odp in enumerate(odpowiedzi):
                        p.odpowiedzi[i].odpowiedz = odp.strip()
                    baza.session.commit()
                    flash(u'Zmieniono pytanie:', 'sukces')
            except KeyError:  # dodajemy nowe pytanie, brak id pytania!
                p = Pytanie(pytanie=pytanie.strip(), odpok=odpok.strip())
                baza.session.add(p)
                baza.session.commit()
                for odp in odpowiedzi:
                    o = Odpowiedz(pnr=p.id, odpowiedz=odp.strip())
                    baza.session.add(o)
                baza.session.commit()
                flash(u'Dodano pytanie:', 'sukces')

Po otworzeniu adresu /dodaj otrzymujemy żądanie GET, na które odpowiadamy zwróceniem omówionego wyżej szablonu dodaj.html. Jeżeli jednak otrzymujemy dane z formularza, na początku dokonujemy prostej walidacji, tj. sprawdzamy, czy użytkownik nie przesyła pustego pytania lub odpowiedzi, dodatkowo, czy podał odpowiedni numer odpowiedzi poprawnej.

Obiekt request.form zawiera wszystkie dane przesłane w ramach żądania. Jeżeli wśród nich nie ma identyfikatora pytania, co oznaczałoby edycję, generowany jest wyjątek, który przechwytujemy za pomocą konstrukcji try: ... except KeyError: i dodajemy nowe pytanie. Tworzymy więc nowy obiekt pytania (p = Pytanie(pytanie = pytanie.strip(), odpok = odpok.strip())) i używając odpowiednich metod zapisujemy. Podobnie dalej odczytujemy w pętli przesłane odpowiedzi, dla każdej tworzymy nowy obiekt (o = Odpowiedz(pnr = p, odpowiedz = odp.strip())) i zapisujemy.

Trochę więcej zachodu wymaga aktualizacja danych. Na początku pobieramy obiekt reprezentujemy edytowane pytanie i odpowiedzi na nie. W Peewee kod jest cokolwiek rozbudowany: p = Pytanie.select(Pytanie,Odpowiedz).join(Odpowiedz).where(Pytanie.id == int(request.form['id'])).get(), w SQLAlchemy jest krócej: p = Pytanie.query.get(request.form['id']). Później odpowiednim polom przypisujemy nowe dane. Więcej różnic występuje dalej. W Peewee przeglądamy listę obiektów reprezentujących odpowiedzi, w każdym zmieniamy odpowiednią właściwość (o.odpowiedz = odpowiedzi[i].strip()) i zapisujemy zmiany. w SQLAlchemy iterujemy po przesłanych odpowiedziach, które zapisujemy w obiektach odpowiedzi odczytywanych bezpośrednio z obiektu reprezentującego pytanie (p.odpowiedzi[i].odpowiedz = odp.strip()).

Zapisywanie lub aktualizacja danych kończy się wygenerowaniem odpowiedniego komunikatu dla użytkownika, np. flash(u'Dodano pytanie:','sukces'). Podobnie wcześniej, jeżeli podczas walidacji otrzymanych danych pojawi się błąd, komunikat o nim zostanie zapisany w liście error[], a później przekazany użytkownikowi w kodzie: for e in error: flash(e, 'blad'). Warto zwrócić tu uwagę na dodatkowe argumenty w funkcji flash, wskazują one rodzaj przekazywanych informacji, co wykorzystujemy we wzorcu szkielet.html. Pętla {% for kategoria, komunikat in get_flashed_messages(with_categories=true) %} w zmiennej kategoria odczytuje omawiane dodatkowe argumenty i używa jej do oznaczenia klasy CSS decydującej o sposobie wyświetlenia danej informacji: <span class="{{ kategoria }}">{{ komunikat }}</span>.

../../_images/quiz2_4.png

7.3.7.4. Widok edycji i usuwanie

Można zadać pytanie, jak do szablonu dodaj.html trafiają pytania, które chcemy edytować. Odpowiada za to widok edytuj()

Peewee. Kod nr
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
@app.route('/edytuj', methods=['GET', 'POST'])
def edytuj():
    pytania = Pytanie().select().annotate(Odpowiedz)
    if not pytania.count():
        flash(u'Brak pytań w bazie.', 'kom')
        return redirect(url_for('index'))

    if request.method == 'POST':
        pid = request.form['id']
        pytanie = Pytanie.select(Pytanie, Odpowiedz).join(
            Odpowiedz).where(Pytanie.id == int(pid)).get()
        return render_template('dodaj.html', pytanie=pytanie)

    return render_template('edytuj.html', pytania=pytania)

Na początku pobieramy wszystkie pytania przy użyciu takiego samego kodu jak w widoku quiz() i sprawdzamy, czy w ogóle jakieś są. Jeżeli tak, przekazujemy pytania do szablonu edytuj.html.

Szablon edytuj.html. Kod nr
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!-- quiz2pw/templates/edytuj.html -->
{% extends "szkielet.html" %}
{% block tytul %}Edycja{% endblock%}
{% block h1 %}Quiz 2 &#8211; edycja pytań{% endblock%}
{% block tresc %}
    <!-- pętla odczytująca kolejne pytania z listy -->
    <ol>
    {% for p in pytania %}
        <li>
            <!-- wypisujemy pytanie -->
            <input type="text" value="{{ p.pytanie }}" name="pyt[]" />
                <form method="POST" action="{{ url_for('edytuj') }}">
                    <!-- wstawiamy id pytania -->
                    <input type="hidden" name="id" value="{{ p.id }}"/>
                    <button type="submit">Edytuj</button>
                </form>
                <form method="POST" action="{{ url_for('usun') }}">
                    <!-- wstawiamy id pytania -->
                    <input type="hidden" name="id" value="{{ p.id }}"/>
                    <button type="submit">Usuń</button>
                </form>
        </li>
    {% endfor %}
    </ol>
{% endblock %}

Zadaniem szablonu jest wyświetlenie treści pytań i dwóch przycisków typu submit, umożliwiających edycję lub usunięcie pytania. Przyciski te są częścią formularzy, które zawierają tylko jedno ukryte pole przechowujące id pytania. O tym, gdzie trafia identyfikator decyduje atrybutu action w formularzu: {{ url_for('edytuj') }} lub {{ url_for('usun') }}. Używamy tu funkcji url_for, która na podstawie podanego widoku generuje odpowiadający mu adres url.

Jeżeli użytkownik wybierze edycję, do omawianego widoku edytuj() trafia żądanie POST, które obsługujemy w ten sposób, że na podstawie odebranego identyfikatora tworzymy obiekt z żądanym pytaniem i odpowiedziami (w SQLAlchemy stosujemy tu polecenie: Pytanie.query.get(pid)), a następnie każemy go wyrenderować w szablonie dodaj.html. Działanie tego szablonu omówiono wyżej. Jeżeli użytkownik kliknie przycisk Usuń jego żądanie trafia do widoku usun(). Funkcja ta przedstawia się następująco:

Peewee. Kod nr
106
107
108
109
110
111
112
113
@app.route('/usun', methods=['POST'])
def usun():
    """Usunięcie pytania o identyfikatorze pid"""
    pid = request.form['id']
    pytanie = Pytanie.get(Pytanie.id == int(pid))
    pytanie.delete_instance(recursive=True)
    flash(u'Usunięto pytanie {0}'.format(pid), 'sukces')
    return redirect(url_for('index'))

Działanie jest proste. Tworzymy obiekt reprezentujący pytanie o przesłanym identyfikatorze i wywołujemy metodę, która go usuwa.. W Peewee korzystamy z polecenia: Pytanie.get(Pytanie.id == int(pid)) i metody delete_instance(recursive = True); dodatkowy argument recursive zapewnia kaskadowe usunięcie wszystkich odpowiedzi. W SQLAlchemy pozyskany obiekt p = Pytanie.query.get(pid) usuwamy za pomocą metody sesji baza.session.delete(p), którą finalnie zapisujemy baza.session.commit(). Na koniec wywołujemy za pomocą tzw. przekierowania widok strony głównej (return redirect(url_for('index'))), który wyświetli przygotowane dla użytkownika komunikaty. Nota bene, podobnie postąpiliśmy również w innych omówionych wyżej widokach.

../../_images/quiz2_5.png

7.3.7.5. Poćwicz sam

Spróbuj napisać wersję omówionej w innym scenariuszu aplikacji ToDo przy wykorzystaniu wybranego systemu ORM, tj. Peewee lub SQLAlchemy.

7.3.8. Źródła

Kompletne wersje kodu znajdziesz w powyższym archiwum w podkatalogach quiz2_pw i quiz2_sa. Uruchamiamy je poleceniami:

~/quiz2/quiz2_orm$ python main.py

- gdzie orm jest oznaczeniem modułu obsługi bazy danych, pw dla Peewee, sa dla SQLALchemy.


Licencja Creative Commons Materiały Python 101 udostępniane przez Centrum Edukacji Obywatelskiej na licencji Creative Commons Uznanie autorstwa-Na tych samych warunkach 4.0 Międzynarodowa.

Utworzony:2017-09-08 o 19:38 w Sphinx 1.4.5
Autorzy:Patrz plik “Autorzy”