Table of Contents
- Django (klasyczny) — Kompletny przewodnik po zadaniach
- Wymagania wstępne
- Zadanie 01: Podstawowa lista zadań (To-Do)
- Zadanie 02: Blog osobisty
- Zadanie 03: Profil użytkownika i przesyłanie plików
- Zadanie 04: Menedżer zadań z zadaniami w tle
- Zadanie 05: Dashboard pogodowy
- Zadanie 06: Czat w czasie rzeczywistym
- Zadanie 07: Obsługa formularzy i szablony
- Zadanie 08: Odbiornik webhook
- Zadanie 09: Zarządzanie magazynem
- Zadanie 10: Bezpieczny magazyn dokumentów
Django (klasyczny) — Kompletny przewodnik po zadaniach
Niniejszy przewodnik omawia wszystkie dziesięć zadań znajdujących się w katalogu django-assignments/classical/. Klasyczne Django opiera się na szablonach HTML renderowanych po stronie serwera — serwer generuje kompletne strony HTML i dostarcza je bezpośrednio do przeglądarki. Każde zadanie stanowi niezależny projekt Django o spójnej strukturze dwóch pakietów: config/ zawierającego ustawienia projektu oraz app/ zawierającego kod aplikacji.
Wymagania wstępne
Wersja języka Python
python --version # wymagana wersja 3.10 lub nowsza
Środowisko wirtualne
python -m venv venv
source venv/bin/activate # macOS/Linux
venv\Scripts\activate # Windows
Narzędzie manage.py
Każde zadanie uruchamia się w ten sam sposób:
pip install Django
python manage.py migrate
python manage.py runserver
Serwer deweloperski nasłuchuje pod adresem http://127.0.0.1:8000 i automatycznie przeładowuje się po zapisaniu pliku.
Struktura projektu
Każdy folder zadania korzysta z tej samej struktury:
<zadanie>/
├── manage.py
├── config/
│ ├── settings.py ← baza danych, zainstalowane aplikacje, konfiguracja plików statycznych/mediów
│ ├── urls.py ← routing główny → włącza app/urls.py
│ └── asgi.py ← punkt wejścia ASGI (używany w zadaniu 06)
└── app/
├── models.py ← tabele bazy danych
├── views.py ← obsługa żądań
├── urls.py ← wzorce URL aplikacji
├── admin.py ← rejestracja w panelu administracyjnym Django
└── templates/
└── app/ ← szablony HTML
Zadanie 01: Podstawowa lista zadań (To-Do)
Lokalizacja: django-assignments/classical/01-basic-todo-api/
Czego się nauczysz
- Definiowania modelu Django i wykonywania migration
- Obsługi żądań GET i POST w jednej funkcji widoku
- Używania dekoratora
@require_http_methodsdo ograniczania dopuszczalnych metod HTTP - Stosowania redirect po żądaniu POST w celu zapobiegania ponownemu przesłaniu formularza
- Częściowego zapisu modelu z użyciem
update_fields
Kluczowe koncepcje
@require_http_methods(["GET", "POST"]) to decorator zwracający odpowiedź 405 Method Not Allowed, gdy żądanie dociera z inną metodą HTTP (np. DELETE, PUT). Jest to lekkie zabezpieczenie eliminujące potrzebę ręcznego sprawdzania if request.method not in (...).
redirect("index") wysyła odpowiedź 302 Found wskazującą na URL o nazwie "index". Po pomyślnym przetworzeniu żądania POST zawsze należy stosować redirect zamiast bezpośredniego renderowania — zapobiega to ponownemu przesłaniu formularza przez przeglądarkę przy odświeżeniu strony (wzorzec POST/Redirect/GET).
save(update_fields=["done"]) nakazuje Django wykonanie zapytania UPDATE dotyczącego wyłącznie kolumny done, zamiast aktualizowania wszystkich kolumn wiersza. W przypadku modeli z wieloma polami jest to znacznie wydajniejsze i chroni przed przypadkowym nadpisaniem danych, gdy wiele procesów zapisuje ten sam obiekt jednocześnie.
Konfiguracja
pip install Django
python manage.py migrate
python manage.py runserver
Pliki projektu
app/models.py
class Todo(models.Model):
title = models.CharField(max_length=200)
done = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
Parametr auto_now_add=True ustawia znacznik czasu jednorazowo — w momencie tworzenia rekordu — i nigdy go nie zmienia. Należy odróżnić go od auto_now=True, który aktualizuje znacznik czasu przy każdym wywołaniu save().
app/views.py
@require_http_methods(["GET", "POST"])
def index(request):
if request.method == "POST":
title = (request.POST.get("title") or "").strip()
if title:
Todo.objects.create(title=title)
return redirect("index")
todos = Todo.objects.all().order_by("-created_at")
return render(request, "app/index.html", {"todos": todos})
@require_http_methods(["POST"])
def toggle_done(request, todo_id: int):
todo = get_object_or_404(Todo, id=todo_id)
todo.done = not todo.done
todo.save(update_fields=["done"])
return redirect("index")
@require_http_methods(["POST"])
def delete(request, todo_id: int):
todo = get_object_or_404(Todo, id=todo_id)
todo.delete()
return redirect("index")
Widoki toggle_done i delete akceptują wyłącznie żądania POST. W HTML nie istnieją przyciski DELETE — konwencjonalnym sposobem wyzwalania tych akcji z poziomu przeglądarki jest użycie <form method="post"> wskazującego na URL odpowiedniej operacji.
app/urls.py
urlpatterns = [
path("", index, name="index"),
path("todos/<int:todo_id>/toggle/", toggle_done, name="toggle_done"),
path("todos/<int:todo_id>/delete/", delete, name="delete"),
]
Testowanie krok po kroku
- Otwórz
http://127.0.0.1:8000/. Zobaczysz pustą listę i formularz. - Wprowadź tytuł i prześlij formularz. Strona przeładuje się z nowym elementem.
- Kliknij „Toggle" przy elemencie — zostanie oznaczony jako ukończony.
- Kliknij „Delete" — element zniknie z listy.
- Wejdź na
http://127.0.0.1:8000/admin/(po uprzednim utworzeniu superużytkownika poleceniempython manage.py createsuperuser), aby zarządzać zadaniami bezpośrednio z panelu administracyjnego.
Zadanie 02: Blog osobisty
Lokalizacja: django-assignments/classical/02-personal-blog/
Czego się nauczysz
- Korzystania z pola
SlugFieldi jego ograniczeń - Wzorców URL opartych na slug z użyciem
<slug:slug> - Wzorca dwóch szablonów: widok listy + widok szczegółowy
- Tworzenia treści za pośrednictwem panelu administracyjnego Django
Kluczowe koncepcje
SlugField to wariant pola CharField weryfikującego format slug — dopuszczalne są wyłącznie małe litery, cyfry i łączniki. Ustawienie unique=True tworzy ograniczenie unikalności na poziomie bazy danych, gwarantując, że żadne dwa wpisy nie będą współdzielić tego samego slug, niezależnie od walidacji na poziomie aplikacji.
Konwerter URL <slug:slug> dopasowuje wyłącznie ciągi znaków mające postać prawidłowego slug i przekazuje wartość do widoku jako typ str. URL /posts/my-first-post/ przekazuje "my-first-post" jako slug.
get_object_or_404(Post, slug=slug) to uproszczony zapis wykonujący Post.objects.get(slug=slug), który w przypadku braku wpisu zwraca wbudowaną odpowiedź 404 Django. Jest to podejście czytelniejsze i bardziej jednoznaczne niż opakowanie wywołania get() w blok try/except.
Konfiguracja
pip install Django
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Pliki projektu
app/models.py
class Post(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
app/views.py
def index(request):
posts = Post.objects.all().order_by("-created_at")
return render(request, "app/index.html", {"posts": posts})
def detail(request, slug: str):
post = get_object_or_404(Post, slug=slug)
return render(request, "app/detail.html", {"post": post})
app/urls.py
urlpatterns = [
path("", index, name="index"),
path("posts/<slug:slug>/", detail, name="detail"),
]
Testowanie krok po kroku
- Zaloguj się do
http://127.0.0.1:8000/admin/i utwórz dwa lub trzy wpisy z unikalnymi slug (np.hello-world,second-post). - Wejdź na
http://127.0.0.1:8000/— pojawi się lista wpisów. - Kliknij tytuł wpisu. URL zmieni się na
http://127.0.0.1:8000/posts/hello-world/. - Spróbuj utworzyć drugi wpis z takim samym slug w panelu administracyjnym. Django odrzuci operację, wyświetlając błąd walidacji.
- Spróbuj uzyskać dostęp do nieistniejącego slug:
http://127.0.0.1:8000/posts/does-not-exist/→ odpowiedź 404.
Zadanie 03: Profil użytkownika i przesyłanie plików
Lokalizacja: django-assignments/classical/03-user-profile-uploads/
Czego się nauczysz
- Korzystania z pola
FileFieldz parametremupload_to - Stosowania
auto_now=Truedo znaczników czasu „ostatniej modyfikacji" - Odczytywania przesłanych plików z
request.FILES - Warunkowej aktualizacji wyłącznie awatara, gdy dostarczono nowy plik
- Serwowania plików mediów w środowisku deweloperskim
Kluczowe koncepcje
auto_now=True aktualizuje pole do bieżącego znacznika czasu przy każdym wywołaniu save(), co czyni je idealnym rozwiązaniem do śledzenia „ostatniej modyfikacji". W odróżnieniu od auto_now_add=True nie można go ustawić ręcznie.
upload_to="avatars/" instruuje Django, aby umieszczał przesłane pliki w katalogu MEDIA_ROOT/avatars/. Ustawienia MEDIA_ROOT i MEDIA_URL (w pliku config/settings.py) muszą być skonfigurowane, a w środowisku deweloperskim pomocnik static() w config/urls.py odpowiada za serwowanie tych plików.
Warunkowa aktualizacja pliku: Jeśli użytkownik prześle formularz profilu bez wyboru nowego pliku, request.FILES.get("avatar") zwróci None. Widok sprawdza ten warunek i zastępuje profile.avatar wyłącznie wtedy, gdy faktycznie przesłano nowy plik:
if avatar is not None:
profile.avatar = avatar
Bez tego sprawdzenia przesłanie formularza bez pliku spowodowałoby usunięcie istniejącego awatara.
Konfiguracja
pip install Django pillow
python manage.py migrate
python manage.py runserver
Biblioteka pillow jest wymagana przez Django przy użyciu pola ImageField. Zadanie korzysta z FileField (akceptującego dowolny plik), więc pillow jest opcjonalne, lecz zalecane dla kompletnej konfiguracji.
Pliki projektu
app/models.py
class Profile(models.Model):
display_name = models.CharField(max_length=100)
bio = models.TextField(blank=True)
avatar = models.FileField(upload_to="avatars/", blank=True, null=True)
updated_at = models.DateTimeField(auto_now=True)
app/views.py
@require_http_methods(["GET", "POST"])
def index(request):
profile = Profile.objects.first()
if request.method == "POST":
display_name = (request.POST.get("display_name") or "").strip()
bio = (request.POST.get("bio") or "").strip()
avatar = request.FILES.get("avatar")
if profile is None:
profile = Profile.objects.create(
display_name=display_name, bio=bio, avatar=avatar)
else:
profile.display_name = display_name
profile.bio = bio
if avatar is not None:
profile.avatar = avatar
profile.save()
return redirect("index")
return render(request, "app/index.html", {"profile": profile})
Formularz w szablonie musi zawierać atrybut enctype="multipart/form-data", aby przesyłanie pliku działało poprawnie:
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
...
</form>
Bez atrybutu enctype przeglądarka wysyła formularz jako tekst zakodowany w URL, a request.FILES jest puste.
Testowanie krok po kroku
- Otwórz
http://127.0.0.1:8000/. Profil jest początkowo pusty. - Wypełnij nazwę wyświetlaną, opis i wybierz plik. Prześlij formularz — profil zostanie utworzony.
- Prześlij formularz ponownie bez wyboru nowego pliku — istniejący awatar zostaje zachowany.
- Awatar jest dostępny pod adresem
http://127.0.0.1:8000/media/avatars/<nazwa_pliku>.
Zadanie 04: Menedżer zadań z zadaniami w tle
Lokalizacja: django-assignments/classical/04-task-manager-bg-tasks/
Czego się nauczysz
- Korzystania z
transaction.on_commit— właściwego miejsca do wyzwalania efektów ubocznych w Django - Stosowania daemon threads do lekkiego przetwarzania w tle
- Dlaczego uruchamianie wątku bezpośrednio po
save()może powodować problemy wyścigu danych
Kluczowe koncepcje
transaction.on_commit(callback) rejestruje funkcję do wykonania po pomyślnym zatwierdzeniu bieżącej transakcji bazy danych. Jest to kluczowe dla operacji w tle odczytujących dane właśnie zapisane: uruchomienie wątku bezpośrednio po save() może nastąpić przed zatwierdzeniem transakcji — inne połączenie z bazą danych w ramach tego wątku mogłoby odczytać nieaktualne lub brakujące dane. on_commit gwarantuje, że dane są widoczne dla wszystkich połączeń przed uruchomieniem callback.
Daemon threads (daemon=True) to wątki, które nie blokują zakończenia procesu Python. Jeśli serwer WWW zostanie wyłączony podczas działania daemon thread, wątek jest natychmiast przerywany. Dla niekrytycznych powiadomień jest to akceptowalne. Do krytycznych operacji (np. przetwarzania płatności) należy stosować właściwą kolejkę zadań.
def _notify_after_commit(task_id: int) -> None:
transaction.on_commit(
lambda: threading.Thread(
target=_send_notification,
args=(task_id,),
daemon=True
).start()
)
Wyrażenie lambda odracza tworzenie wątku do momentu zatwierdzenia transakcji. Wątek uruchamia się tylko wtedy, gdy transakcja zakończy się sukcesem — jeśli save() zostanie wycofane z powodu wyjątku, żaden wątek nie zostanie utworzony.
Konfiguracja
pip install Django
python manage.py migrate
python manage.py runserver
Pliki projektu
app/views.py
@require_http_methods(["POST"])
def complete(request, task_id: int):
task = get_object_or_404(Task, id=task_id)
if not task.completed:
task.completed = True
task.save(update_fields=["completed"])
_notify_after_commit(task.id)
return redirect("index")
Warunek if not task.completed zapobiega podwójnemu ukończeniu zadania: kliknięcie „Complete" na już ukończonym zadaniu nie powoduje żadnej akcji ani nie wyzwala powiadomienia.
Testowanie krok po kroku
- Utwórz kilka zadań za pomocą formularza.
- Kliknij „Complete" przy zadaniu. Strona natychmiast wykona redirect.
- Ponieważ
_send_notificationjest zaślepką w tym szkielecie, terminal nie wyświetla żadnych komunikatów. Aby zaobserwować wzorzec przetwarzania w tle: zastąpreturnw_send_notificationwywołaniemtime.sleep(3); print(f"Notified for task {task_id}")i powtórz test.
Zadanie 05: Dashboard pogodowy
Lokalizacja: django-assignments/classical/05-weather-dashboard/
Czego się nauczysz
- Przesyłania formularza metodą GET (parametry zapytania)
- Wywoływania zewnętrznego API za pomocą biblioteki
requests - Korzystania z
django.core.cachedo buforowania w pamięci z TTL - Korzystania z API JSON serwisu
wttr.in
Kluczowe koncepcje
wttr.in to bezpłatne API pogodowe niewymagające uwierzytelniania. Endpoint https://wttr.in/{city}?format=j1 zwraca szczegółową odpowiedź JSON. To inne API niż Open-Meteo stosowane w zadaniach innych frameworków — przyjmuje nazwy miast bezpośrednio, co upraszcza formularz do jednego pola tekstowego.
django.core.cache udostępnia zunifikowany interfejs buforowania. Domyślny backend (LocMemCache) przechowuje dane w pamięci procesu. cache.set(key, value, timeout=300) zapisuje wartość z TTL wynoszącym 5 minut; cache.get(key) zwraca None, jeśli wpis jest nieobecny lub wygasł.
cache_key = f"weather:{city.lower()}"
data = cache.get(cache_key)
if data is None:
r = requests.get(f"https://wttr.in/{city}", params={"format": "j1"}, timeout=5)
r.raise_for_status()
data = r.json()
cache.set(cache_key, data, timeout=300)
Klucz cache używa city.lower(), dzięki czemu zapytania „London" i „london" trafiają do tego samego wpisu w cache.
Formularz GET: Formularz wyszukiwania korzysta z method="get", co dołącza nazwę miasta do URL jako parametr zapytania (?city=London). Sprawia to, że strona wyników jest zakładkowalna — URL w pełni opisuje treść zapytania.
Konfiguracja
pip install Django requests
python manage.py migrate
python manage.py runserver
Testowanie krok po kroku
- Otwórz
http://127.0.0.1:8000/. Prześlij formularz z wartością „London". - Pojawią się dane pogodowe dla Londynu. Zwróć uwagę na czas odpowiedzi.
- Prześlij „London" ponownie — odpowiedź jest niemal natychmiastowa (serwowana z cache).
- Prześlij „Paris" — wykonywane jest prawdziwe żądanie do API.
Częste problemy
requests.exceptions.Timeout— w kodzie zdefiniowano limit czasu wynoszący 5 sekund dla API wttr.in. Przy wolnym połączeniu sieciowym może pojawić się komunikat błędu renderowany w szablonie.KeyErrorw szablonie — struktura JSON serwisuwttr.injest zagnieżdżona. Aby zbadać jej kształt, użyj powłoki Django:python manage.py shell→import requests; print(requests.get("https://wttr.in/London?format=j1").json().keys()).
Zadanie 06: Czat w czasie rzeczywistym
Lokalizacja: django-assignments/classical/06-real-time-chat/
Czego się nauczysz
- Dlaczego standardowe Django (WSGI) nie obsługuje WebSocket
- Czym jest ASGI i jak działa
ProtocolTypeRouter - Jak korzystać z Django Channels i klasy
AsyncWebsocketConsumer - Jak działają channel layer i rozsyłanie grupowe
- Jak uruchomić Django pod serwerem Daphne (ASGI)
Kluczowe koncepcje
WSGI a ASGI: Standardowy protokół wdrożenia Django (WSGI) jest synchroniczny i oparty na modelu żądanie-odpowiedź — połączenie jest otwierane, zwracana jest odpowiedź, a następnie połączenie jest zamykane. WebSocket pozostaje otwarty przez czas nieokreślony. Django Channels dodaje warstwę ASGI, która obsługuje zarówno żądania HTTP, jak i trwałe połączenia WebSocket współbieżnie.
ProtocolTypeRouter w pliku config/asgi.py kieruje przychodzące połączenia do różnych procedur obsługi na podstawie typu protokołu:
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(URLRouter(app.routing.websocket_urlpatterns)),
})
Żądania HTTP są obsługiwane przez standardowy mechanizm widoków Django. Połączenia WebSocket przechodzą przez URLRouter, który dopasowuje ścieżkę do wzorców websocket_urlpatterns.
AsyncWebsocketConsumer jest klasą bazową dla procedur obsługi WebSocket. Cykl życia:
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.group_name = "chat"
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept() # zakończenie uzgadniania WebSocket
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
payload = json.loads(text_data)
username = (payload.get("username") or "anonymous")[:50]
text = (payload.get("text") or "")[:2000]
await self.channel_layer.group_send(
self.group_name,
{"type": "chat.message", "username": username, "text": text},
)
async def chat_message(self, event): # wywoływana przez group_send
await self.send(text_data=json.dumps({
"username": event["username"],
"text": event["text"]
}))
Channel layer to system przekazywania wiadomości współdzielony między wszystkimi instancjami konsumentów (wszystkimi połączonymi użytkownikami). group_send publikuje wiadomość do nazwanej grupy; każdy konsument w grupie odbiera ją za pośrednictwem metody, której nazwa odpowiada wartości event["type"] (kropki są zastępowane podkreśleniami: chat.message → chat_message).
Walidacja danych wejściowych jest stosowana w metodzie receive: nazwy użytkowników są ograniczone do 50 znaków, a treści wiadomości do 2000, zgodnie z limitami pól modelu.
Konfiguracja
pip install Django channels channels-redis
# Wymaga działającej instancji Redis dla channel layer.
# Do lokalnego dewelopmentu bez Redis użyj warstwy in-memory:
# pip install channels (InMemoryChannelLayer nie wymaga Redis)
python manage.py migrate
Uruchomienie z serwerem Daphne (ASGI):
pip install daphne
daphne config.asgi:application
Lub z Uvicorn:
pip install uvicorn
uvicorn config.asgi:application --reload
Polecenie python manage.py runserver korzysta z WSGI i nie obsługuje WebSocket.
Testowanie krok po kroku
- Uruchom serwer za pomocą Daphne lub Uvicorn.
- Otwórz
http://127.0.0.1:8000/w pierwszej karcie przeglądarki. - Otwórz ten sam adres w drugiej karcie.
- Wyślij wiadomość z karty 1. Natychmiast pojawia się w karcie 2.
- Zamknij kartę 1. Karta 2 nadal działa — metoda
disconnectkonsumenta czysto usuwa zamknięte połączenie z grupy.
Zadanie 07: Obsługa formularzy i szablony
Lokalizacja: django-assignments/classical/07-forms-templates/
Czego się nauczysz
- Korzystania z frameworku
messagesDjango do jednorazowych powiadomień dla użytkownika - Zapisywania zgłoszeń formularzy w bazie danych
- Rozróżniania walidacji (pola wymagane) od tworzenia obiektów modelu
- Praktycznego zastosowania wzorca POST/Redirect/GET
Kluczowe koncepcje
django.contrib.messages to framework oparty na plikach cookie/sesji służący do jednorazowych powiadomień. Po pomyślnym przesłaniu formularza dodajesz wiadomość i wykonujesz redirect:
messages.success(request, "Message sent")
return redirect("index")
Przy następnym żądaniu (GET po przekierowaniu) szablon renderuje wiadomość, która jest następnie automatycznie usuwana. Wiadomości przeżywają dokładnie jedno żądanie — nie są wyświetlane ponownie przy kolejnych odświeżeniach strony.
Trwałość w przeglądarce a baza danych: Zadanie zapisuje każde zgłoszenie formularza kontaktowego w tabeli ContactMessage. Jest to właściwe podejście dla formularza kontaktowego — chcemy mieć zapis, kto i co napisał. Należy odróżnić to od samego frameworku messages, który niczego trwale nie przechowuje.
Konfiguracja
pip install Django
python manage.py migrate
python manage.py runserver
Pliki projektu
app/models.py
class ContactMessage(models.Model):
email = models.EmailField()
topic = models.CharField(max_length=100)
message = models.TextField(max_length=2000)
created_at = models.DateTimeField(auto_now_add=True)
EmailField przechowuje wartość jako VARCHAR i stosuje walidację formatu adresu e-mail w panelu administracyjnym Django. W tym widoku walidacja jest ręczna; w podejściu opartym na klasach formularzy EmailField walidowałby automatycznie.
app/views.py
@require_http_methods(["GET", "POST"])
def index(request):
if request.method == "POST":
email = (request.POST.get("email") or "").strip()
topic = (request.POST.get("topic") or "").strip()
message = (request.POST.get("message") or "").strip()
if email and topic and message:
ContactMessage.objects.create(email=email, topic=topic, message=message)
messages.success(request, "Message sent")
return redirect("index")
return render(request, "app/index.html")
Szablon renderuje listę wiadomości za pomocą {% if messages %}...{% for m in messages %}{{ m }}{% endfor %}{% endif %}.
Testowanie krok po kroku
- Otwórz
http://127.0.0.1:8000/. - Prześlij formularz kontaktowy z wypełnionymi wszystkimi polami. Na przekierowanej stronie pojawi się baner sukcesu.
- Odśwież stronę. Baner zniknął — wiadomości są jednorazowe.
- Prześlij formularz z pustym polem. Formularz wyrenderuje się ponownie bez komunikatu i bez przekierowania. Aby ulepszyć ten przypadek, dodaj jawne komunikaty błędów dla niekompletnych zgłoszeń.
Zadanie 08: Odbiornik webhook
Lokalizacja: django-assignments/classical/08-webhook-receiver/
Czego się nauczysz
- Wyłączania ochrony CSRF dla endpoint przeznaczonych do komunikacji maszynowej
- Weryfikacji podpisu HMAC-SHA256
- Walidacji znacznika czasu w celu zapobiegania atakom powtórki (replay attacks)
- Idempotentności poprzez deduplikację za pomocą
event_id - Przechowywania surowych payload webhook w polu
JSONField
Kluczowe koncepcje
Ataki powtórki (replay attacks): Atakujący, który przechwyci prawidłowe żądanie webhook, mógłby je ponownie wysłać, aby dwukrotnie wywołać tę samą akcję (np. podwoić zaksięgowaną płatność). Walidacja znacznika czasu ogranicza to ryzyko: odbiornik odrzuca żądania, w których header X-Timestamp jest starszy niż 300 sekund. Jest to rozwiązanie bardziej zaawansowane niż implementacje webhook w FastAPI, Flask i Hono.
Deduplikacja za pomocą X-Event-Id: Niektórzy dostawcy ponownie próbują dostarczyć nieudane webhook. Header event_id jednoznacznie identyfikuje każde zdarzenie. Przed przetworzeniem odbiornik sprawdza, czy zdarzenie o tym samym zestawieniu dostawca+event_id nie zostało już zapisane:
if event_id and WebhookEvent.objects.filter(
provider=provider, event_id=event_id).exists():
return HttpResponse("duplicate event", status=409)
Dzięki temu endpoint jest idempotentny — dwukrotne przetworzenie tego samego zdarzenia nie wywołuje żadnego efektu.
JSONField przechowuje słownik Pythona jako kolumnę JSON w bazie danych. Django 3.1+ zawiera JSONField w standardowych modelach. Pole jest dostępne do zapytań: WebhookEvent.objects.filter(payload__event="payment.success").
Ważne — request.body a request.POST: HMAC jest obliczany na surowych bajtach. Użycie request.body daje bezpośredni dostęp do tych bajtów. Jeśli najpierw zostanie wywołane request.POST, Django odczyta i sparsuje treść, opróżniając leżący u podstaw strumień — request.body byłoby wtedy puste.
raw = request.body # surowe bajty — używane do HMAC
provided = request.headers.get("X-Signature", "")
expected = hmac.new(
settings.WEBHOOK_SECRET.encode("utf-8"),
raw,
hashlib.sha256
).hexdigest()
valid = hmac.compare_digest(expected, provided)
WEBHOOK_SECRET jest odczytywany z pliku settings.py. W środowisku produkcyjnym powinien być pobierany ze zmiennej środowiskowej.
Konfiguracja
pip install Django
python manage.py migrate
python manage.py runserver
Testowanie krok po kroku
Użyj skryptu Python, aby wysłać poprawnie podpisane żądanie:
import hashlib, hmac, json, time
import requests
SECRET = "change-me"
PROVIDER = "github"
payload = {"event": "push", "repo": "my-project"}
body = json.dumps(payload).encode()
ts = str(int(time.time()))
event_id = "evt-001"
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
resp = requests.post(
f"http://127.0.0.1:8000/webhooks/{PROVIDER}/",
data=body,
headers={
"Content-Type": "application/json",
"X-Signature": sig,
"X-Timestamp": ts,
"X-Event-Id": event_id,
},
)
print(resp.status_code, resp.text)
- Poprawny podpis →
200 ok - Błędny podpis →
401 invalid signature(zdarzenie i tak jest zapisywane zsignature_valid=False) - Znacznik czasu starszy niż 300 s →
400 timestamp too old - Ten sam
event_idponownie →409 duplicate event
Zadanie 09: Zarządzanie magazynem
Lokalizacja: django-assignments/classical/09-inventory-management/
Czego się nauczysz
- Korzystania z klasy
PaginatorDjango - Stosowania obiektów
Qdo zapytań opartych na operatorze OR - Zachowywania parametrów zapytania w linkach paginacji
- Wyszukiwania bez rozróżniania wielkości liter za pomocą
__icontains
Kluczowe koncepcje
Obiekty Q umożliwiają łączenie warunków filtrowania operatorami logicznymi. Standardowe wywołanie .filter(name=q, sku=q) generuje zapytanie AND (oba pola muszą pasować). Aby przeszukiwać po którymkolwiek z pól, należy użyć operatora | (OR):
qs = qs.filter(Q(name__icontains=q) | Q(sku__icontains=q))
__icontains to dopasowanie podciągu bez rozróżniania wielkości liter (odpowiednik LIKE %q% w SQL).
Paginator(queryset, per_page) dzieli queryset na strony. Metoda get_page(n) jest odporna na błędy — dla n < 1 zwraca pierwszą stronę, a dla n > num_pages ostatnią, więc nieprawidłowe wartości ?page= nigdy nie powodują błędów.
paginator = Paginator(qs, 20)
page_obj = paginator.get_page(request.GET.get("page") or "1")
Obiekt page_obj przekazany do szablonu udostępnia: page_obj.object_list (elementy bieżącej strony), page_obj.has_previous(), page_obj.has_next(), page_obj.previous_page_number(), page_obj.next_page_number(), page_obj.number, page_obj.paginator.num_pages.
Zachowywanie parametrów zapytania w linkach paginacji: Jeśli użytkownik szuka q=bolt, link „Następna" musi zawierać frazę wyszukiwania: ?q=bolt&page=2. W szablonie:
<a href="?q={{ q }}&page={{ page_obj.next_page_number }}">Next →</a>
Konfiguracja
pip install Django
python manage.py makemigrations
python manage.py migrate
python manage.py runserver
Wypełnij bazę danych za pośrednictwem panelu administracyjnego Django (najpierw python manage.py createsuperuser) lub polecenia zarządzania.
Testowanie krok po kroku
- Dodaj 25 lub więcej elementów za pomocą panelu administracyjnego.
- Otwórz
http://127.0.0.1:8000/. Strona 1 wyświetla 20 elementów z linkiem „Następna". - Kliknij „Następna" → strona 2.
- Skorzystaj z pola wyszukiwania, wpisując część nazwy lub numeru SKU. Wyniki są filtrowane i paginowane niezależnie.
- Wyszukiwanie z paginacją: sprawdź, czy fraza wyszukiwania jest zachowana w linkach paginacji.
Zadanie 10: Bezpieczny magazyn dokumentów
Lokalizacja: django-assignments/classical/10-secure-document-vault/
Czego się nauczysz
- Stosowania dekoratora
@login_requiredi uwierzytelniania opartego na sesjach - Używania wbudowanych grup Django jako mechanizmu ról
- Kontroli dostępu na poziomie obiektów: użytkownik może pobierać wyłącznie własne dokumenty
- Bezpiecznego dostarczania plików za pomocą
FileResponse - Stosowania
Http404jako odpowiedzi odmawiającej dostępu
Kluczowe koncepcje
@login_required przekierowuje nieuwierzytelnione żądania do /accounts/login/?next=<oryginalny_url>. Wbudowany widok logowania Django obsługuje formularz; po pomyślnym zalogowaniu użytkownik jest przekierowywany z powrotem na oryginalną stronę.
Grupy jako role: Django zawiera model Group. Dodanie użytkownika do grupy "admin" przyznaje dostęp na poziomie administratora bez nadawania pełnych uprawnień superużytkownika. Funkcja pomocnicza:
def _is_admin(user) -> bool:
return user.is_superuser or user.groups.filter(name="admin").exists()
Superużytkownicy pomijają sprawdzanie przynależności do grupy, co jest spójne z modelem uprawnień Django (superużytkownicy zawsze przechodzą wszystkie sprawdzenia uprawnień).
Kontrola dostępu na poziomie obiektów w widoku download:
@login_required
def download(request, doc_id: int):
doc = get_object_or_404(Document, id=doc_id)
if not (_is_admin(request.user) or doc.owner_id == request.user.id):
raise Http404()
return FileResponse(
doc.file.open("rb"),
as_attachment=True,
filename=doc.file.name.rsplit("/", 1)[-1]
)
Zwracanie odpowiedzi Http404 zamiast 403 Forbidden jest celowym wyborem: zapobiega ujawnieniu informacji o istnieniu dokumentów, do których użytkownik nie ma dostępu. Atakujący enumerujący identyfikatory dokumentów nie jest w stanie odróżnić „ten dokument nie istnieje" od „ten dokument istnieje, ale nie masz do niego dostępu".
FileResponse strumieniuje plik z dysku do klienta bez wczytywania go w całości do pamięci. Parametr as_attachment=True ustawia nagłówek Content-Disposition: attachment, co powoduje, że przeglądarka pobiera plik zamiast próbować go wyświetlić. Parametr filename oczyszcza zapisaną ścieżkę — rsplit("/", 1)[-1] usuwa ewentualny prefiks katalogu.
ForeignKey(settings.AUTH_USER_MODEL, ...) korzysta z ustawienia zamiast bezpośredniego importowania modelu User. Dzięki temu kod jest zgodny z projektami używającymi niestandardowego modelu użytkownika.
Konfiguracja
pip install Django
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
Aby utworzyć zwykłego użytkownika i użytkownika należącego do grupy admin na potrzeby testów, skorzystaj z panelu administracyjnego Django pod adresem http://127.0.0.1:8000/admin/.
Testowanie krok po kroku
- Zaloguj się jako superużytkownik. Prześlij dokument za pomocą
http://127.0.0.1:8000/upload/. - Wejdź na
http://127.0.0.1:8000/. Dokument widoczny jest na liście. - Kliknij „Download". Plik zostanie dostarczony jako załącznik.
- Utwórz drugiego użytkownika w panelu administracyjnym (bez przypisania do grup). Zaloguj się jako ten użytkownik.
- Lista jest pusta — drugi użytkownik nie posiada żadnych dokumentów.
- Spróbuj pobrać dokument superużytkownika, zgadując jego URL:
http://127.0.0.1:8000/documents/1/download/→ odpowiedź 404. - Dodaj drugiego użytkownika do grupy
"admin"w panelu administracyjnym Django. Zaloguj się ponownie. Teraz wszystkie dokumenty są widoczne i możliwe do pobrania.