Tvorba internetových aplikací

Jakub Klinkovský

:: České vysoké učení technické v Praze
:: Fakulta jaderná a fyzikálně inženýrská
:: Katedra softwarového inženýrství

Akademický rok 2022-2023

Ukázka pokročilejší aplikace

Viz soubor music library.zip v Moodle.

Prvky aplikace:

  • definice modelů pro žánry, muzikanty, kapely a alba
  • testovací data v souborech fixtures
  • hlavní stránka s přehledem obsahu databáze
  • podstránky se seznamy objektů jednotlivých modelů
  • CSS pro použité elementy

Dále si ukážeme technické provedení této aplikace (v projektu se jmenuje musiclib).

Definice modelů

Soubor musiclib/models.py obsahuje modely Genre, Musician, Band, Album.

class Genre(models.Model):
    """ A simple model with just one attribute: name of a music genre. """
    name = models.CharField(max_length=50)

    def __str__(self):
        """ Returns a string identifying the object. """
        return self.name

Nové prvky:

  • tzv. docstring – způsob dokumentace kódu (více na Real Python)
  • metoda __str__ – čitelná reprezentace objektu pomocí stringu (používá ji funkce str)

Definice modelu Musician

class Musician(models.Model):
    """ A musician is a person who plays an instrument or sings.

        Wikipedia has a better definition, but this application is not
        the most general. See https://en.wikipedia.org/wiki/Musician
    """
    first_name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    instrument = models.CharField(max_length=100)

    def __str__(self):
        """ Returns a string identifying the object. """
        return self.first_name + " " + self.last_name

Nové prvky:

  • víceřádkový docstring, metoda __str__ využívající více atributů

Definice modelu Band

class Band(models.Model):
    """ A band is a named group of musicians. """
    name = models.CharField(max_length=50)
    members = models.ManyToManyField(Musician)

    def __str__(self):
        """ Returns a string identifying the object. """
        return self.name

Nové prvky:

  • atribut typu ManyToManyField (kapela má několik členů, a současně muzikant může být členem několika kapel)

Práce s vazbou typu ManyToManyField

  • automatická dekompozice pomocí dodatečné tabulky (viz program Sqlite Browser)

  • vazba je dostupná z obou propojených tabulek:

    • Band má atribut members, Musician má automatický atribut band_set
    • oba jsou objekty typu ManyRelatedManager a reprezentují množinu objektů z propojené tabulky (metody add(), all(), filter(), count(), atd.)
  • před vložením vazby je nutné uložit (save()) oba propojované objekty!

  • zkuste v Python shellu (python manage.py shell):

    >>> from musiclib.models import *
    >>> m = Musician.objects.last()
    >>> m.band_set.count()
    >>> b = Band.objects.first()
    >>> b.members.count()
    

Definice modelu Album

class Album(models.Model):
    """
    An album is a sequence of songs by a musician or a band.

    The `musician` and `band` attributes can be NULL, but not at the same time.
    This is checked at the application level in the `clean` method.
    """
    name = models.CharField(max_length=100)
    musician = models.ForeignKey(Musician, on_delete=models.CASCADE,
                                 default=None, null=True, blank=True)
    band = models.ForeignKey(Band, on_delete=models.CASCADE,
                             default=None, null=True, blank=True)
    genre = models.ForeignKey(Genre, on_delete=models.DO_NOTHING)
    release_date = models.DateField()
    num_stars = models.IntegerField(default=0)

Nové prvky:

  • parametry default (None odpovídá NULL v databázi), null, blank

Metody modelu Album

    def __str__(self):
        """ Returns a string identifying the object. """
        return self.name

    def clean(self):
        """ Validates attributes of the object as a whole. """
        if self.musician is None and self.band is None:
            raise ValidationError("Album entries must have either musician or band defined.")
        if self.musician is not None and self.band is not None:
            raise ValidationError("Album entries must not have both musician and band "
                                  "set at the same time.")

    def format_stars(self):
        """ Returns a string representation of the stars set for the album. """
        return "⭐" * self.num_stars

Nové prvky:

  • validace objektu na úrovni aplikace (ne databáze) pomocí metody clean
    • další možnost validace: definovat atribut jako field s parametrem validators
  • vlastní metoda format_stars pro formátování hvězdiček (lze použít v šablonách)

Úprava definice modelů

Po změně definice modelů mohou nastat problémy:

  • automatické generování migrace se nemusí povést
  • provedení migrace v databázi se nemusí povést

Zkuste např.:

  • přidat nový povinný atribut, tj. s null=False (default)
  • přejmenovat atribut (existuje automatická detekce kterou uživatel musí potvrdit, ta ale nefunguje při změně parametrů atributu)
  • smazat nějaký atribut – smažou se i data!

Automaticky vygenerované migrace je možné ručně upravit pro vlastní potřeby.

Testovací data pro vývoj databáze

Při vývoji aplikace není potřeba se vždy zabývat vytvářením správné migrace a někdy je lepší začít tzv. od začátku (resp. posledního funkčního stavu, který je např. nasazen na veřejném serveru).

Workflow s restartem:

  • smazat databázi (soubor db.sqlite3)
  • vytvořit databázi v počátečním stavu (dle stavu souboru models.py)
    • python manage.py makemigrations
    • python manage.py migrate
  • inicializovat databázi testovacími daty – tzv. fixtures
  • pracovat na úpravách modelů

Testovací data – fixtures

Adresář musiclib/fixtures/ obsahuje testovací data pro danou aplikaci:

  • několik souborů, které je třeba nahrát ve správném pořadí dle závislostí mezi modely, např. python manage.py loaddata genres musicians bands albums

  • formát souborů: viz dokumentace Django

  • definice dat pro ManyToManyField: seznam hodnot PK, na která se odkazuje:

    {
        "model": "musiclib.Band",
        "pk": 1,
        "fields": {
            "name": "The Beatles",
            "members": [2, 3, 4, 5]
        }
    },
    

Třída Meta – definice dodatečných vlastností modelů

Třída Meta vnořená do definice modelu umožňuje definovat dodatečné vlastnosti nebo modifikovat chování modelu, např.:

class Genre(models.Model):
    name = models.CharField(max_length=50)

    class Meta:
        ordering = ["name"]   # defaultní způsob řazení objektů ("-name" obrátí pořadí)

Dokumentace obsahuje seznam dostupných Meta-parametrů.

Nejčastěji používané parametry jsou ordering, indexes a constraints.

Možnosti pro další rozšíření modelů

  • muzikanti hrající na více nástrojů (string, pole nebo samostatná tabulka?)
  • hierarchie žánrů (podskupiny)
  • atribut žánru pro kapely a muzikanty
  • obecnější definice muzikanta (dle Wikipedie)
  • historie členství muzikantů v kapelách (velmi komplikované...)

Definice pohledů a šablon

V souboru musiclib/views.py jsou definovány dva pohledy: index a list_.

def index(request):
    # set dynamic variables for the template
    variables = {}
    variables["albums_count"] = models.Album.objects.count()
    variables["musicians_count"] = models.Musician.objects.count()
    variables["bands_count"] = models.Band.objects.count()
    variables["genres_count"] = models.Genre.objects.count()

    # most popular albums
    variables["popular_albums"] = models.Album.objects.all().order_by("-num_stars")[:5]

    # render the template
    return render(request, "musiclib/index.html", variables)

Nové prvky: metoda count(), metoda order_by("-num_stars"), omezení množiny výsledků pomocí [:5]

Definice šablony index.html

Šablona musiclib/index.html rozšiřuje šablonu musiclib/base.html. Obě mají podobnou strukturu jako předchozí aplikace pokus. Významné prvky jsou:

  • použití proměnných s filtrem default v úvodním textu:
    <p>V současné době je zde {{ albums_count|default:0 }} alb od {{ musicians_count|default:0 }}
       hudebníků a {{ bands_count|default:0 }} kabel v {{ genres_count|default:0 }} žánrech.</p>
    
  • kontrola atributu album.musician při sestavování seznamu nejpopulárnějších alb:
    {% if album.musician %}
        {{ album.musician.first_name }} {{ album.musician.last_name }}
    {% else %}
        {{ album.band.name }}
    {% endif %}
    
  • použití metody album.format_stars:
    {{ album.name }} ({{ album.format_stars }})
    

Definice pohledu list_

Název je list_, protože list je funkce jazyka Python (vytváří seznam).

Pohled má parametr what, který udává typ objektů, které se mají zobrazit:

def list_(request, *, what):
    # validate the input parameter
    if what not in ["genres", "musicians", "bands", "albums"]:
        # return a HTTP 404 error
        return HttpResponseNotFound(f"Litujeme, ale položky typu '{what}' v knihovně nemáme.")

Odpovídající mapování v souboru urls.py:

urlpatterns = [
    ...
    path("list/<slug:what>", views.list_, name="list"),
    ...
]

Definice pohledu list_

Dále jsou definovány dynamické proměnné pro použití v šabloně musiclib/list.html

    ...

    # set dynamic variables for the template
    variables = {}
    variables["what"] = what
    if what == "genres":
        variables["what_str"] = "žánrů"
        variables["genres"] = models.Genre.objects.all().order_by("name")
    elif what == "musicians":
        ...
    ...

    return render(request, "musiclib/list.html", variables)

Definice šablony list.html

Šablona musiclib/list.html opět rozšiřuje šablonu musiclib/base.html.

Obsah je rozdělen dle hodnoty proměnné what:

{% if what == "genres" %}
    ...
{% elif what == "musicians" %}
    ...
{% elif what == "bands" %}
    ...
{% elif what == "albums" %}
    ...
{% endif %}

(Možná lepší řešení by bylo mít čtyři samostatné šablony.)

Definice šablony list.html

  1. Seznam žánrů – přímočaré generování číslovaného seznamu pomocí cyklu.

  2. Seznam muzikantů – generování seznamu kapel oddělených čárkou v závorkách:

    {% if musician.band_set.count > 0 %}
        {% for band in musician.band_set.all %}
            {% if forloop.first %}({% endif %}{{ band.name }}{% if forloop.last %}){% else %},{% endif %}
        {% endfor %}
    {% endif %}
    

    Všimněte si speciálních proměnných forloop.first a forloop.last.

  3. Seznam kapel – generování seznamu členů oddělených čárkou (podobný způsob).

  4. Seznam alb – generování tabulky pomocí cyklu:

    • ověření atributu album.musician, použití filtru date, použití metody album.format_stars.

Možnosti pro další rozšíření pohledů

  • stránkování seznamů
    • stránka by nikdy neměla zobrazovat neomezený počet objektů!
    • z důvodu efektivity je nutné seznam rozdělit na několik stránek a poskytnout navigaci mezi stránkami (dopředu/dozadu, první/poslední, ...)
    • Django poskytuje třídu Paginator – použití si ukážeme jindy
  • lepší CSS, více obsahu...
  • možnost vyhledávání/filtrování

Formuláře

Formuláře v HTML

Formuláře poskytují standardní způsob pro odesílání dat z klienta na server. Princip je založen na několika vrstvách:

  1. značky HTML – poskytují uživatelské rozhraní
  2. protokol HTTP – přenos dat v rámci požadavku (běžně se používají metody GET a POST)
  3. serverová aplikace – stará se o zpracování dat a o vytvoření odpovědi

Formuláře ve frameworku Django

  1. Formulář lze definovat několika způsoby:
    • vlastnoruční definice pomocí značek v nějaké šabloně
    • objektová definice pomocí API pro formuláře
  2. Data přenesená od uživatele jsou transparentně dekódovaná a přístupná v objektu HttpRequest – atribut request.GET nebo request.POST
  3. První krok při zpracování dat z formuláře by vždy měla být validace!
    • pro vlastnoruční formulář je nutné provést ručně
    • pro objektový formulář lze využít pokročilé nástroje

Příklad: aplikaci, která je komentovaná na následujících slajdech, najdete v Moodle v souboru pokus formuláře.zip.

Vlastnoruční definice formuláře

<form action="{% url 'index' %}" method="post">
    {% csrf_token %}
    <fieldset>
        <legend>Add new musician</legend>
        {% if error_message %}<p class="error">{{ error_message }}</p>{% endif %}
        <label for="fname">First name:</label>
        <input type="text" id="fname" name="first_name"><br>
        <label for="lname">Last name:</label>
        <input type="text" id="lname" name="last_name"><br>
        <label for="inst">Instrument:</label>
        <input type="text" id="inst" name="instrument"><br>
    </fieldset>
    <input type="submit" value="Send">
</form>
  • atribut action obsahuje URL, kam se mají data poslat metodou v atr. method
  • {% csrf_token %} poskytuje ochranu proti CSRF útokům
  • atribut for obsahuje hodnotu id cílového elementu
  • atribut name slouží jako klíč odesílané hodnoty/proměnné

Vlastnoruční validace dat z formuláře

Pro jednoduchost vše v jednom pohledu:

def index(request):
    # set dynamic variables for the template
    variables = {}
    ...

Dále ověříme metodu požadavku a přečteme parametry formuláře:

    if request.method == 'POST':
        first_name = request.POST.get("first_name")
        last_name = request.POST.get("last_name")
        instrument = request.POST.get("instrument")

Poznámka: na slovník request.POST je důležité použít metodu get() místo operátoru [] a získat defaultní hodnotu None (kód musí fungovat, i když některé parametry formuláře chybí).

Validace dat z formuláře pomocí validace modelu

Účelem našeho formuláře je vložit data do databáze. Proto pro validaci můžeme vytvořit objekt daného modelu a použít metodu full_clean():

        # create an object
        musician = models.Musician(first_name=first_name, last_name=last_name,
                                   instrument=instrument)

        try:
            # validate the input data and save the object
            musician.full_clean()
            musician.save()

            # Always return an HttpResponseRedirect after successfully dealing
            # with POST data. This prevents data from being posted twice if the
            # user hits the <Back> or <Reload> button.
            return HttpResponseRedirect(reverse("index"))

Po zpracování dat poslaných metodou POST je nutné vrátit přesměrování, aby se zabránilo duplicitním požadavkům (např. po obnovení stránky) – viz PRG pattern.

Validace dat z formuláře – dokončení

V případě výjimky typu ValidationError je potřeba zobrazit chybu uživateli – k tomu slouží proměnná error_message použitá v naší šabloně při definici formuláře:

        except ValidationError as e:
            variables["error_message"] = "Some form fields are not valid:"
            for field, messages in e:
                message = " ".join(messages)
                variables["error_message"] += f" {field}{message}"

    # continue with normal rendering
    variables["musicians"] = models.Musician.objects.all().order_by("last_name", "first_name")
    return render(request, "pokus/index.html", variables)

Po validaci pokračujeme se standardním zpracováním požadavku (poslední dva řádky jsou mimo blok if request.method == "POST" a provedou se tedy i pro obě metody).

Objektové API pro práci s formuláři

Třída Form poskytuje základ pro objektovou práci s formuláři (analogie třídy Model).

  • odvozené třídy reprezentují jednotlivé formuláře
  • prvky formuláře – fields – stejně jako pro modely, ale z modulu django.forms

Stručný přehled funkcionality:

  • odvozená třída ModelForm – umožňuje automatické mapování atributů daného modelu na prvky formuláře
  • rozhraní pro validaci dat – metody clean() a is_valid()
  • rozhraní pro renderování do HTML – metody as_div(), as_p(), as_ul(), as_table()

Více najdete v dokumentaci k formulářovému API.

Definice objektového formuláře

V souboru pokus/forms.py definujeme formulář pro model Musician:

from django import forms

class MusicianForm(forms.Form):
    error_css_class = "error"

    first_name = forms.CharField(label="First name", max_length=50)
    last_name = forms.CharField(label="Last name", max_length=50)
    instrument = forms.CharField(label="Instrument", max_length=100)

    def clean(self):
        if " " in self.cleaned_data["instrument"]:
            raise forms.ValidationError("instrument must be just one word")

Poznámka: fields ve formuláři jsou defaultně povinné (parametr required=True).

Poznámka: metoda clean slouží k validaci dat (podobně jako v rozhraní modelů).

Použití objektového formuláře v pohledu

V souboru pokus/views.py si připravíme nový pohled:

from . import forms

def form2(request):
    # set dynamic variables for the template
    variables = {}

    ### zde vytvoříme a zpracujeme formulář - proměnná form

    # continue with normal rendering
    variables["musicians"] = models.Musician.objects.all().order_by("last_name", "first_name")
    variables["form"] = form
    return render(request, "pokus/form2.html", variables)

Vytvoření a zpracování objektového formuláře

Základní kostra je stejná jako dříve. Formulář vytvoříme buď prázdný nebo s daty:

    # check if there are input data to be processed
    if request.method == "POST":
        # create a form with data from the request
        form = forms.MusicianForm(request.POST)
        # check if the form is valid
        if form.is_valid():
            first_name = form.cleaned_data["first_name"]
            last_name = form.cleaned_data["last_name"]
            instrument = form.cleaned_data["instrument"]

            ### zpracování dat - vytvoření objektu a vložení do databáze
            ### stejně jako v manuálním případě - včetně validace modelu!

    # create an empty form for GET or any other method
    else:
        form = forms.MusicianForm()

Použití formuláře v šabloně

V šabloně lze použít některou z metod as_div(), as_p(), as_ul(), as_table():

<form action="{% url 'form2' %}" method="post">
    {% csrf_token %}
    <fieldset>
        <legend>Add new musician</legend>
        {% if error_message %}<p class="error">{{ error_message }}</p>{% endif %}
        {{ form.as_div }}
    </fieldset>
    <input type="submit" value="Send">
</form>

Poznámka: z vlastnoručního řešení zůstaly jen značky form, fieldset, legend, tlačítko submit a případné chyby.