MVT (Model-View-Template) to wzorzec architektury Django: model opisuje dane i tabelę w bazie, widok (*view*) to funkcja lub klasa, która obsługuje żądanie i wybiera dane, a szablon (*template*) to plik HTML, który te dane wyświetla. Rolę kontrolera z MVC przejmuje w Django widok razem z routingiem w urls.py, dlatego „view” w Django odpowiada kontrolerowi, a nie szablonowi.Hasło „MVT” pojawia się w każdym wstępie do Django, ale rzadko ktoś pokazuje, gdzie dokładnie leży granica między M, V i T. Ten artykuł prowadzi przez jedną małą aplikację, listę wpisów i stronę wpisu, i na niej pokazuje, co robi każda z trzech warstw. Ogólne wprowadzenie do frameworka znajdziesz w artykule co to jest Django i do czego służy, tutaj skupiamy się na działającym kodzie. Wszystkie przykłady uruchomiliśmy w świeżym projekcie (Django 6.1.3, Python 3.13), a wyniki w tekście pochodzą z tego uruchomienia.
Czym jest MVT w Django i czym różni się od MVC?
MVT to trzy warstwy aplikacji z jasno przypisanymi zadaniami:
| Warstwa | Za co odpowiada | Gdzie leży w projekcie |
|---|---|---|
| Model | opis danych i tabeli, zapytania do bazy (ORM) | models.py |
| View (widok) | przyjmuje żądanie, pobiera dane, zwraca odpowiedź | views.py |
| Template (szablon) | wygląd: HTML z miejscami na dane | templates/ |
Dochodzi do tego czwarty element, który nie ma własnej litery: plik urls.py, czyli routing. To on dopasowuje adres URL do widoku.
W klasycznym MVC (Model-View-Controller) podział jest podobny, ale nazwy są przesunięte. Porównanie:
| MVC | MVT w Django | Uwaga |
|---|---|---|
| Model | Model | to samo |
| Controller | View + urls.py | „view” w Django robi to, co kontroler |
| View | Template | „widok” z MVC to szablon w Django |
To jest najważniejsza pułapka językowa: „view” w Django nie jest plikiem HTML. HTML to szablon. Jeśli znasz Laravela, tam „widok” oznacza plik Blade (opisujemy go w artykule Blade w Laravel), więc te same słowa znaczą coś innego w obu frameworkach.
Jak wygląda projekt Django, który pokazuje MVT?
Projekt tworzysz poleceniami django-admin startproject i manage.py startapp. W naszym przykładzie projekt nazywa się config, a aplikacja blog:
django-admin startproject config .
python manage.py startapp blog
Aplikację trzeba dopisać do INSTALLED_APPS w config/settings.py, inaczej Django jej nie zobaczy:
INSTALLED_APPS = [
# ... domyślne aplikacje Django ...
"blog",
]
Po tych krokach struktura wygląda tak: blog/models.py (M), blog/views.py (V), blog/urls.py (routing) i blog/templates/blog/ (T). Szablony leżą w podkatalogu o nazwie aplikacji, bo Django zbiera katalogi templates/ ze wszystkich aplikacji w jedno miejsce, a powtarzająca się nazwa blog/ chroni przed zderzeniem nazw plików z innymi aplikacjami.
Jak wygląda Model w MVT?
Model to klasa Pythona, z której Django buduje tabelę. Nasz wpis ma tytuł, treść, flagę publikacji i datę utworzenia:
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
Po python manage.py makemigrations blog Django odpowiedział Create model Post, a migrate utworzył tabelę. Model nic nie wie o HTML-u ani o adresach URL: odpowiada tylko za dane. Zapytania piszesz przez ORM, np. Post.objects.filter(published=True), a nie ręcznym SQL-em.
Jak wygląda View w MVT?
Widok to funkcja, która dostaje obiekt żądania (request) i zwraca odpowiedź. Tu napisaliśmy dwa: listę wpisów oraz stronę pojedynczego wpisu.
from django.shortcuts import get_object_or_404, render
from .models import Post
def post_list(request):
posts = Post.objects.filter(published=True).order_by("-created_at")
return render(request, "blog/post_list.html", {"posts": posts})
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk, published=True)
return render(request, "blog/post_detail.html", {"post": post})
Widok robi trzy rzeczy: pobiera dane z modelu, przekazuje je do szablonu jako słownik (kontekst, context) i zwraca gotową odpowiedź z funkcji render. Nie zawiera ani linijki HTML-a. Funkcja get_object_or_404 zwraca stronę 404, gdy wpisu nie ma albo gdy nie jest opublikowany, czyli widok jest też miejscem na reguły dostępu.
Jak działa routing w urls.py?
Routing łączy adres z widokiem. Aplikacja ma własny plik blog/urls.py:
from django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path("", views.post_list, name="post_list"),
path("<int:pk>/", views.post_detail, name="post_detail"),
]
a główny config/urls.py dołącza go pod prefiksem posts/:
from django.urls import include, path
urlpatterns = [
path("posts/", include("blog.urls")),
]
Fragment <int:pk> przechwytuje liczbę z adresu i przekazuje ją widokowi jako argument pk. Zmienna app_name = "blog" tworzy przestrzeń nazw (namespace): nazwę widoku zapisujesz potem jako blog:post_detail. Do tej pułapki wracamy w sekcji o błędach.
Jak wygląda Template w MVT?
Szablon to HTML z dwoma rodzajami wstawek: {{ zmienna }} wypisuje wartość, a {% tag %} wykonuje logikę (pętla, warunek, dziedziczenie). Zaczynamy od szablonu bazowego blog/base.html:
<!DOCTYPE html>
<html lang="pl">
<head><title>{% block title %}Blog{% endblock %}</title></head>
<body>
{% block content %}{% endblock %}
</body>
</html>
Szablon listy rozszerza go tagiem extends i wypełnia bloki:
{% extends "blog/base.html" %}
{% block title %}Posty{% endblock %}
{% block content %}
<h1>Posty</h1>
{% for post in posts %}
<li><a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a></li>
{% empty %}
<p>Brak postów.</p>
{% endfor %}
{% endblock %}
Tag {% for ... %}{% empty %} obsługuje pustą listę w jednej konstrukcji, a {% url 'blog:post_detail' post.pk %} buduje adres z nazwy widoku, więc zmiana ścieżki w urls.py nie wymaga poprawek w szablonach. Mechanizm dziedziczenia jest tu odpowiednikiem layoutów z Blade (@extends, @yield).
Co się dzieje po kolei, gdy wchodzisz na /posts/?
Droga jednego żądania przez wszystkie warstwy:
| Krok | Co się dzieje | Warstwa |
|---|---|---|
| 1 | przeglądarka wysyła GET /posts/ | — |
| 2 | Django szuka dopasowania w urls.py: posts/ → blog.urls → post_list | routing |
| 3 | wywołuje post_list(request) | View |
| 4 | widok pyta model o opublikowane wpisy | Model |
| 5 | widok przekazuje wpisy do render() razem z nazwą szablonu | View |
| 6 | Django składa szablon z post_list.html i base.html | Template |
| 7 | gotowy HTML wraca do przeglądarki | odpowiedź |
Sprawdziliśmy to w teście. Dla pustej bazy GET /posts/ zwrócił kod 200 i <p>Brak postów.</p>. Po dodaniu jednego opublikowanego wpisu „Pierwszy” i jednego szkicu na liście pojawił się tylko pierwszy:
<h1>Posty</h1>
<li><a href="/posts/1/">Pierwszy</a></li>
Szkic był widoczny dla autora w bazie, ale nie dla odwiedzającego: wejście na jego stronę zwróciło 404, tak samo jak adres /posts/999/. Klient testowy pokazał też, które szablony złożyły stronę: blog/post_list.html i blog/base.html.
Czy Django escapuje dane w szablonie tak jak Blade?
Tak, domyślnie. {{ zmienna }} zamienia znaki specjalne HTML na encje, a filtr |safe albo tag {% autoescape off %} wyłącza tę ochronę. Zapisaliśmy wpis z treścią <script>alert(1)</script> i wyrenderowaliśmy go dwa razy:
<div>{{ post.body }}</div>
<div>{{ post.body|safe }}</div>
Wynik:
<div><script>alert(1)</script></div>
<div><script>alert(1)</script></div>
Pierwszy wiersz to bezpieczny tekst, a w drugim przeglądarka wykona skrypt. Filtr |safe ma więc to samo ryzyko co {!! !!} w Blade: jeśli dane pochodzą od użytkownika, otwierasz drogę do ataku opisanego w artykule XSS w JavaScript. Zasada jest ta sama: |safe tylko dla HTML-a, który sam wygenerowałeś lub oczyściłeś.
Czym szablony Django różnią się od Blade?
Ta sama rola (T w MVT, V w MVC), ale inna filozofia. Różnice, które wynikają z naszych testów i dokumentacji:
| Cecha | Szablony Django | Blade (Laravel) |
|---|---|---|
| Składnia wypisania | {{ zmienna }} | {{ $zmienna }} |
| Logika | tagi {% if %}, {% for %} | dyrektywy @if, @foreach |
| Kod PHP/Pythona w szablonie | celowo ograniczony, nie napiszesz dowolnego kodu | @php ... @endphp pozwala na dowolny kod |
| Brakująca zmienna | cicho wypisuje pusty tekst | błąd Undefined variable |
| Dziedziczenie | {% extends %} + {% block %} | @extends + @section / @yield |
| Filtry | {{ tekst|upper }} | wywołanie funkcji PHP w {{ }} |
Różnica w brakujących zmiennych jest ważna w praktyce. W teście Cześć {{ imie }}! bez przekazanej zmiennej dało Cześć !, a {{ post.nie_ma }} dało pusty tekst. Django nie zgłosi błędu, więc literówka w nazwie zmiennej potrafi tygodniami zostać niezauważona. Gdy strona wyświetla puste miejsce zamiast danych, sprawdź nazwę zmiennej w szablonie i w słowniku z widoku.
Jakie filtry i tagi szablonów warto znać na początek?
| Element | Przykład | Wynik w teście |
|---|---|---|
upper | {{ t|upper }} dla devmentor | DEVMENTOR |
truncatechars | {{ t|truncatechars:6 }} | devme… |
default | {{ n|default:'brak' }} dla pustego tekstu | brak |
length, join | {{ l|length }}, {{ l|join:', ' }} dla [1, 2, 3] | 3, 1, 2, 3 |
date | {{ post.created_at|date:"Y-m-d" }} | 2026-11-04 |
{% if %} z and/not | {% if a and not b %}tak{% endif %} | tak |
{% include %} | {% include 'blog/base.html' %} | wstawia cały plik |
{{ block.super }} | w bloku title dziecka | Blog - X (treść bloku rodzica + dopisek) |
Filtry łączysz znakiem | i wywołujesz z jednym argumentem po dwukropku. Skomplikowanych obliczeń nie umieszczaj w szablonie: przenieś je do widoku lub modelu.
Jakie błędy w MVT popełniają początkujący?
| Błąd | Objaw | Co zrobić |
|---|---|---|
Zła nazwa lub ścieżka szablonu w render() | TemplateDoesNotExist: blog/nie_ma.html | sprawdź, czy plik leży w templates/blog/ i czy nazwa zgadza się znak w znak |
Aplikacja nie jest w INSTALLED_APPS | też TemplateDoesNotExist (wg dokumentacji; tego wariantu nie testowaliśmy) | dopisz aplikację do INSTALLED_APPS |
{% url 'post_detail' %} bez przestrzeni nazw | NoReverseMatch: 'post_detail' is not a valid view function or pattern name | użyj blog:post_detail, jeśli w urls.py jest app_name = "blog" |
| Literówka w nazwie zmiennej | pusty fragment strony, bez błędu | porównaj nazwę w szablonie z kluczem w słowniku widoku |
| HTML i logika biznesowa w widoku | widok rośnie, trudno go testować | HTML do szablonu, reguły do modelu lub osobnej funkcji |
|safe na danych od użytkownika | luka XSS | usuń |safe albo oczyść dane przed zapisem |
Błąd NoReverseMatch potwierdziliśmy w teście: reverse("post_detail", args=[1]) zgłosił go, a reverse("blog:post_detail", args=[1]) zwrócił poprawnie /posts/1/.
Jaki błąd przy MVT popełniają początkujący najczęściej?
Najczęstsza pomyłka przy MVT to logika w szablonie albo HTML zbudowany w widoku z ciągów znaków. Prosta reguła: widok decyduje, co pokazać, szablon decyduje, jak to wygląda, a model mówi, czym są dane. Jeśli w szablonie piszesz coś, co wymagałoby testu jednostkowego, przenieś to do widoku lub modelu.
Gdzie nauczyć się Django w praktyce?
MVT najłatwiej zrozumieć, budując własną aplikację: model, kilka widoków, szablony i formularz. Taką drogę przechodzisz w kursie Python, który skupia się na Django i obejmuje m.in. modele, formularze i wdrożenie. Mentor sprawdza twój kod i wskazuje miejsca, w których logika wylądowała w złej warstwie.


