Notes Chat App — Deep Dive¶
notes_chat_app — фінальний проєкт курсу на Django 5.2. Поєднує CRUD, групове ділення об'єктів, WebSocket-чат, повну тестову піраміду і Docker Compose в одному codebase.
Навігація¶
| Документ | Призначення |
|---|---|
| Project Overview | ролі, стек, архітектурні патерни |
| Architecture | компоненти, ASGI routing, шари, Docker |
| Domain Model | 9 моделей, ER-діаграма, поля, deletion behavior |
| Feature Map | що реалізовано і де у коді |
| Request Flows | HTTP CRUD, auth, WebSocket flows (Mermaid) |
| Security Model | IDOR, Q-filter, group sharing, WebSocket auth |
| Async and Chat | ASGI, GroupChatConsumer, channel layer |
| Background Tasks | Celery Worker, Beat, email-нагадування, Browser Toast |
| Testing Strategy | 6 test файлів, 205 тестів, TransactionTestCase |
| Setup | запуск через Docker Compose |
| Deployment | Docker сервіси, production checklist |
| Student Tasks | практичні завдання 3 рівнів |
Архітектура одним поглядом¶
flowchart TD
Browser --> Nginx["Nginx :80\n(reverse proxy, static)"]
Nginx -->|HTTP| Daphne["Daphne :8001\nASGI server"]
Nginx -->|WebSocket upgrade| Daphne
Daphne --> Router["ProtocolTypeRouter\nnotes_project/asgi.py"]
Router -->|http| HTTP["Django HTTP stack"]
Router -->|websocket| Auth["AuthMiddlewareStack\n→ URLRouter\n→ GroupChatConsumer"]
HTTP --> Views["views.py\n(thin layer)"]
Views --> Forms["forms.py\n(validation)"]
Views --> Sel["selectors.py\n(read-only ORM)"]
Views --> Srv["services.py\n(mutations)"]
Sel --> ORM["Django ORM"]
Srv --> ORM
ORM --> PG[("PostgreSQL :5432")]
Auth --> Consumer["GroupChatConsumer\nconsumers.py"]
Consumer -->|database_sync_to_async| ORM
Consumer -->|group_send| Redis[("Redis :6379\nchannel layer")]
Redis -->|chat_message| Consumer
Файлова структура¶
notes_chat_app/
├── notes_project/ ← Django project package
│ ├── settings.py ← apps, DB, Channels, Redis, static, auth
│ ├── urls.py ← root URLs: admin, accounts, app include
│ ├── asgi.py ← ProtocolTypeRouter (http + websocket)
│ └── routing.py ← WebSocket URL: /ws/groups/<pk>/chat/
│
├── notes_app/ ← єдиний Django app
│ ├── models.py ← 9 моделей + constraints
│ ├── urls.py ← 35+ URL маршрутів
│ ├── views.py ← FBV, @login_required, тонкий шар
│ ├── forms.py ← NoteForm, GroupCreateForm, ...
│ ├── selectors.py ← read-only QuerySets, access scoping
│ ├── services.py ← мутації: create/update/delete
│ ├── consumers.py ← GroupChatConsumer (AsyncWebsocketConsumer)
│ ├── tasks.py ← @shared_task send_reminder_notifications (Celery)
│ ├── admin.py ← реєстрація моделей
│ └── tests/
│ ├── test_models.py
│ ├── test_services.py
│ ├── test_forms.py
│ ├── test_views.py
│ ├── test_consumers.py
│ ├── test_tasks.py
│ └── test_selenium.py
│
├── templates/ ← project-level templates
│ ├── base.html ← DOCTYPE, head, navbar, messages
│ └── layouts/
│ └── dashboard.html ← sidebar, content block
│
├── notes_app/templates/notes_app/ ← app-level templates
├── notes_app/static/notes_app/js/
│ ├── group_chat.js ← WebSocket JS-клієнт
│ └── reminders.js ← HTTP polling + Bootstrap Toast
│
├── nginx/
│ └── nginx.conf ← proxy_pass, WebSocket upgrade, static
├── docker-compose.yml ← db, redis, web, nginx, ngrok, selenium, celery-worker, celery-beat
├── Dockerfile
└── entrypoint.sh ← wait-for-db, migrate, collectstatic, seed
Шар-модель¶
HTTP Request
└─► notes_project/urls.py (include notes_app.urls)
└─► notes_app/urls.py (@login_required, path)
└─► views.py (coordination only)
├─► forms.py (validation, clean_*)
├─► selectors.py (scoped QuerySet — read)
└─► services.py (create/update/delete)
└─► models.py → PostgreSQL
Правило: views.py не містить ORM-запитів. selectors.py не містить мутацій. services.py не знає про HTTP.
Domain Model¶
erDiagram
AUTH_USER ||--|| USER_PROFILE : "1:1 CASCADE"
AUTH_USER ||--o{ NOTEBOOK : "1:N CASCADE"
AUTH_USER ||--o{ NOTE : "1:N CASCADE"
AUTH_USER ||--o{ TAG : "1:N CASCADE"
AUTH_USER ||--o{ TODO_LIST : "1:N CASCADE"
AUTH_USER ||--o{ SHOPPING_LIST : "1:N CASCADE"
AUTH_USER ||--o{ CHAT_MESSAGE : "1:N CASCADE"
AUTH_GROUP ||--o{ NOTE : "FK SET_NULL"
AUTH_GROUP ||--o{ SHOPPING_LIST : "FK SET_NULL"
AUTH_GROUP ||--o{ CHAT_MESSAGE : "1:N CASCADE"
AUTH_USER }o--o{ TODO_LIST : "shared_with M2M"
AUTH_USER }o--o{ SHOPPING_LIST : "shared_with M2M"
NOTEBOOK ||--o{ NOTE : "FK SET_NULL"
NOTE }o--o{ TAG : "M2M"
NOTE ||--o{ REMINDER : "1:N CASCADE"
TODO_LIST ||--o{ TODO_ITEM : "1:N CASCADE"
SHOPPING_LIST ||--o{ SHOP_ITEM : "1:N CASCADE"
Ключові моделі¶
| Модель | Особливості |
|---|---|
Note |
priority 1–4 (PositiveSmallIntegerField, CheckConstraint), group FK SET_NULL, notebook FK SET_NULL, tags M2M |
TodoList |
shared_with M2M(User) — спільний доступ через прямий шерінг |
ShoppingList |
shared_with M2M(User) + group FK SET_NULL — два типи sharing |
ChatMessage |
author FK CASCADE, group FK CASCADE — без SET_NULL; при видаленні групи повідомлення видаляються |
Reminder |
CASCADE від Note — при видаленні нотатки нагадування видаляються |
UserProfile |
OneToOneField(User, CASCADE) — display_name(100), bio, avatar_url, timezone |
Deletion behavior¶
User deleted → всі його Note, Notebook, Tag, TodoList, ShoppingList, ChatMessage → CASCADE
Notebook deleted → Note.notebook → SET_NULL (нотатки не видаляються)
Note deleted → Reminder → CASCADE
Group deleted → Note.group → SET_NULL, ShoppingList.group → SET_NULL
→ ChatMessage → CASCADE (повідомлення видаляються!)
TodoList deleted → TodoItem → CASCADE
ShoppingList deleted → ShopItem → CASCADE
URL-маршрути¶
Усі маршрути визначені у notes_app/urls.py.
Notes¶
| URL | Name | View |
|---|---|---|
/notes/ |
note_list |
note_list |
/notes/new/ |
note_create |
note_create |
/notes/<pk>/ |
note_detail |
note_detail |
/notes/<pk>/edit/ |
note_edit |
note_edit |
/notes/<pk>/delete/ |
note_delete |
note_delete |
Todo¶
| URL | Name | View |
|---|---|---|
/todo/ |
todo_list |
todo_list_list |
/todo/new/ |
todo_create |
todo_list_create |
/todo/<pk>/ |
todo_detail |
todo_list_detail |
/todo/<pk>/share/ |
todo_share |
todo_list_share |
/todo/<pk>/items/add/ |
todo_item_add |
todo_item_add |
/todo/items/<pk>/toggle/ |
todo_item_toggle |
todo_item_toggle |
Shopping¶
| URL | Name | View |
|---|---|---|
/shopping/ |
shopping_list |
shopping_list_list |
/shopping/new/ |
shopping_create |
shopping_list_create |
/shopping/<pk>/share/ |
shopping_share |
shopping_list_share |
/shopping/<pk>/items/add/ |
shop_item_add |
shop_item_add |
/shopping/items/<pk>/toggle/ |
shop_item_toggle |
shop_item_toggle_purchased |
Groups і Chat¶
| URL | Name | View |
|---|---|---|
/groups/ |
group_list |
group_list |
/groups/new/ |
group_create |
group_create |
/groups/<pk>/ |
group_detail |
group_detail |
/groups/<pk>/delete/ |
group_delete |
group_delete |
/groups/<pk>/chat/ |
group_chat |
group_chat |
WebSocket¶
Визначено у notes_project/routing.py.
Selectors¶
notes_app/selectors.py — read-only ORM, завжди повертає матеріалізовані дані або QuerySet до рендеру.
| Функція | Що повертає |
|---|---|
get_user_notes(user, *, archived, notebook, tag, search) |
QuerySet[Note] з select_related('notebook'), prefetch_related('tags') |
get_note_detail(user, note_id) |
Note або None (owner + group members) |
get_pinned_notes(user, limit=5) |
QuerySet[Note] |
get_user_notebooks(user) |
QuerySet[Notebook] |
get_user_tags(user) |
QuerySet[Tag] |
get_user_todo_lists(user) |
QuerySet[TodoList] (власні) |
get_shared_todo_lists(user) |
QuerySet[TodoList] (shared_with) |
get_todo_list_detail(user, pk) |
TodoList або None |
get_user_shopping_lists(user) |
власні shopping lists |
get_shared_shopping_lists(user) |
shared_with lists |
get_shopping_list_detail(user, pk) |
з group-access |
get_user_groups(user) |
QuerySet[Group] де user є членом |
get_group_with_members(group_id, user) |
group + prefetch members |
get_pending_reminders() |
нагадування з remind_at <= now |
Правило доступу для shared objects:
# read: власник АБО член групи
Q(user=user) | Q(group__in=user.groups.all())
# write: тільки власник
Q(user=user)
Services¶
notes_app/services.py — мутуючі операції, викликаються тільки після валідації форми.
Notes¶
create_note(*, user, title, content='', notebook=None, priority=1, group=None, tag_ids=None)
update_note(note, *, title=None, content=None, priority=None, notebook=None,
is_pinned=None, group=..., tag_ids=None)
delete_note(note)
toggle_pin_note(note) # flip is_pinned
archive_note(note) # flip is_archived
group=... (Ellipsis) у update_note — sentinel "не змінювати". group=None — прибрати групу.
Notebooks, Tags¶
create_notebook(*, user, title, color='#4A90E2', is_default=False)
update_notebook(notebook, *, title, description='', color, is_default)
delete_notebook(notebook)
create_or_get_tag(*, user, name, color='#808080') # get_or_create
Todo¶
create_todo_list(*, user, title, description='')
share_todo_list(todo_list, username) # додає до shared_with
unshare_todo_list(todo_list, username)
add_todo_item(todo_list, *, text, due_date=None)
toggle_todo_item(item)
Shopping¶
create_shopping_list(*, user, title, store_name='', group=None)
share_shopping_list(shopping_list, username)
add_shop_item(shopping_list, *, name, quantity=1, unit='шт', estimated_price=None)
toggle_shop_item_purchased(item)
Groups¶
create_group(*, name, creator) # створює Group + додає creator як member
add_user_to_group(group, username)
GroupChatConsumer¶
notes_app/consumers.py — єдиний WebSocket consumer.
class GroupChatConsumer(AsyncWebsocketConsumer):
async def connect(self) # перевірка auth + membership → accept або close
async def disconnect(self, code) # group_discard
async def receive(self, text_data) # JSON parse → save_message → group_send
async def chat_message(self, event) # broadcast від channel layer → send до клієнта
# sync helpers (викликаються через database_sync_to_async)
def check_membership(self, group_pk, user) → bool
def load_history(self, group_pk) → list[dict] # 50 останніх
def save_message(self, group_pk, user, content) → ChatMessage
ASGI import order (asgi.py):
# ПРАВИЛЬНО: django.setup() → get_asgi_application() → імпорт consumers
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'notes_project.settings')
django_asgi_app = get_asgi_application() # ← ПЕРЕД будь-яким імпортом notes_app
from notes_app.consumers import GroupChatConsumer # ← тільки ПІСЛЯ
database_sync_to_async + матеріалізація:
# ✅ правильно: list() матеріалізує до повернення з worker
history = await database_sync_to_async(self.load_history)(group_pk)
def load_history(self, group_pk):
return list(
ChatMessage.objects.filter(group_id=group_pk)
.select_related('author')
.order_by('-created_at')[:50]
.values('content', 'author__username', 'created_at')
)
Безпека¶
IDOR захист¶
Всі object-level запити використовують scoped queryset — ніколи просто .get(pk=pk):
# selectors.py
def get_note_detail(user, note_id):
user_groups = user.groups.all()
return Note.objects.filter(
Q(user=user) | Q(group__in=user_groups),
pk=note_id
).select_related('notebook', 'group').prefetch_related('tags').first()
Якщо None → view повертає Http404.
Матриця дозволів¶
| Об'єкт | Read | Write (edit/delete) |
|---|---|---|
| Note | owner + group members | owner only |
| Notebook | owner | owner |
| Tag | owner | owner |
| TodoList | owner + shared_with |
owner |
| ShoppingList | owner + shared_with + group |
owner |
| ChatMessage | group members (WS) | — |
WebSocket аутентифікація¶
AuthMiddlewareStack читає session cookie при WS handshake → scope["user"].
Consumer перевіряє user.is_authenticated і check_membership() у connect().
Неавторизований або не-член → await self.close().
Тестова піраміда¶
| Файл | Клас | Кількість | Що тестує |
|---|---|---|---|
test_models.py |
TestCase |
~30 | constraints, __str__, on_delete |
test_services.py |
TestCase |
~40 | return value + persistence |
test_forms.py |
TestCase |
~25 | valid/invalid data, clean_* |
test_views.py |
TestCase |
~70 | status codes, redirects, IDOR |
test_consumers.py |
TransactionTestCase |
~30 | WS connect, message, reject |
test_tasks.py |
TestCase |
~13 | Celery task, email, repeat, JSON view |
test_selenium.py |
StaticLiveServerTestCase |
~14 | E2E flow + Toast-нотифікації |
| Всього | ~218 |
Чому TransactionTestCase для consumers:
TestCase огортає кожен тест у транзакцію → rollback після.
Async consumer читає БД з окремого worker-потоку —
цей потік не бачить незакомічену транзакцію TestCase.
→ setUp-об'єкти "зникають" для consumer.
→ ЗАВЖДИ TransactionTestCase для WebSocket-тестів.
Запуск:
# unit + integration + consumer
docker compose run --rm web python manage.py test \
notes_app.tests.test_models notes_app.tests.test_services \
notes_app.tests.test_forms notes_app.tests.test_views \
notes_app.tests.test_consumers -v 2
# Selenium (потрібен запущений стек)
docker compose up -d
docker compose exec web python manage.py test notes_app.tests.test_selenium -v 2
Docker Compose¶
services:
db: # postgres:16, volume pgdata
redis: # redis:7-alpine — DB 0: Channels, DB 1: Celery
web: # Django Daphne :8001, залежить від db + redis
nginx: # nginx:1.27, :80/:443, proxy_pass до web, роздає static
ngrok: # публічний HTTPS-тунель (розробка)
selenium: # Selenium Grid для E2E тестів
celery-worker: # виконує @shared_task, --concurrency=2
celery-beat: # планувальник (рівно 1 екземпляр), CELERY_BEAT_SCHEDULE
entrypoint.sh виконує при кожному старті:
wait_for_postgres
python manage.py migrate --noinput
python manage.py collectstatic --noinput
[optional] python manage.py seed_demo_data
daphne -b 0.0.0.0 -p 8001 notes_project.asgi:application
Наскрізний сценарій: Create Note¶
Browser views.py forms.py services.py PostgreSQL
| | | | |
|-- GET /notes/new/ ------>| | | |
|<---- NoteForm (empty) ---| | | |
| | | | |
|-- POST /notes/new/ ----->| | | |
| (title, content, tags) |-- NoteForm(data) -> | |
| |<- cleaned_data --| | |
| |-- create_note(user, **data) ---->| |
| | | INSERT Note -|------------->|
| | | <-- Note pk -|-------------|
|<-- redirect /notes/<pk>--| | | |
Наскрізний сценарій: WebSocket Chat¶
Browser JS Nginx Daphne GroupChatConsumer Redis
| | | | |
|-- WS connect ----------> | | |
| (Cookie: sessionid=...) | | |
| |-- upgrade -> | |
| | |-- connect() -> |
| | | check_membership() |
| | | load_history() |
| | | group_add(channel_name) -->|
|<-- history JSON --------|-----------|<- send() ----| |
| | | | |
|-- send message --------> | | |
| {"message": "Hello"} |-- text --> | |
| | |-- receive() -> |
| | | save_message() → DB |
| | | group_send() ------------->|
| | | |
| | |<-- chat_message event -------|
|<-- {"type":"chat", ...}-|-----------|<- send() ----| |
Типові підводні камені¶
| Ситуація | Помилка | Рішення |
|---|---|---|
| Lazy QuerySet з async consumer | SynchronousOnlyOperation |
list() у database_sync_to_async |
get_asgi_application() після імпорту notes_app |
AppRegistryNotReady |
переставити імпорти у asgi.py |
TestCase для consumer |
setUp-об'єкти зникають | TransactionTestCase |
Redis socket_timeout не None |
TimeoutError на idle WS |
"socket_timeout": None у CONFIG |
DebugExceptionMiddleware без if DEBUG: |
traceback у HTTP-відповіді у prod | обгорнути if DEBUG: у settings.py |
note.group = group_instance; note.save() у selector |
порушення шару (мутація) | перенести до services.py |