Notes Project — від проектування моделей до PostgreSQL¶
Цей туторіал проводить тебе через повний цикл: придумати структуру даних → намалювати схему → написати моделі → міграції → запити → selectors/services → шаблони → перехід з SQLite на PostgreSQL.
Проект — персональний менеджер записів: нотатки, нагадування, записники, списки справ, списки покупок. Спеціально вибрані різні типи даних щоб показати всі типи зв'язків між таблицями.
Зміст¶
Архітектурний фундамент (читати перед кодом) - 01 · DOMAIN → SCHEMA — метод проектування - 02 · RELATIONSHIP TYPES — де живе FK - 03 · APPLICATION LAYERS — архітектура шарів - 04 · NORMALIZATION — одна правда для одного факту - 05 · MIGRATIONS — Git для схеми БД
🔬 orm_laboratory.ipynb — інтерактивна лабораторія: виконуй кожну клітинку і дивись SQL
Покрокова реалізація 1. Крок 0 — Від домену до схеми (правильний початок) 2. Крок 1 — Проектуємо домен: що ми будуємо 3. Крок 2 — Ідентифікуємо сутності та зв'язки 4. Крок 3 — ER-діаграми (схеми таблиць) 5. Крок 4 — Пишемо Django моделі 6. Крок 5 — Міграції: схема → база даних 7. Крок 6 — Перший запуск з SQLite 8. Крок 7 — Архітектура: Services & Selectors 9. Крок 8 — Views та шаблони 10. Крок 9 — QuerySet API: основні запити 11. Крок 10 — Перехід з SQLite на PostgreSQL 12. Структура файлів проекту
01 · DOMAIN → SCHEMA¶
"Design before code."
Помилка №1 — починати зclass Note(models.Model)і додавати поля «на ходу».
Через місяць — рефактор схеми = міграція яку важко відкотити в проді.
30 хвилин на проектування = тижні зекономленого часу.
Архітектурна дошка описує 7 кроків від бізнес-слів до робочої бази:
| # | Крок | Що робиш | Приклад |
|---|---|---|---|
| 1 | ДОМЕН | Описати словами що будуєш | «Користувач має записники. Записники містять нотатки. Нотатки мають теги і нагадування.» |
| 2 | СУТНОСТІ | Знайди іменники у тексті | User, Notebook, Note, Tag, Reminder, TodoList, ShoppingList |
| 3 | АТРИБУТИ | Що ми знаємо про кожну сутність? | Note → title, content, priority, is_pinned, updated_at… |
| 4 | ЗВ'ЯЗКИ | Як сутності пов'язані? | 1:1 User↔Profile · 1:N Notebook→Notes · M:N Note↔Tag |
| 5 | НОРМАЛІЗАЦІЯ | Прибери дублювання | author_name у Note? — Ні, лише FK на User |
| 6 | on_delete | Що при видаленні батька? | CASCADE / SET_NULL / PROTECT / RESTRICT |
| 7 | DJANGO МОДЕЛЬ | Тільки тепер пишемо models.py |
← Помилка №1: починати звідси |
⚠️ Anti-pattern. Почати з
class Note(models.Model)і додавати поля «на ходу».
ER-діаграма — це контракт між тобою і базою. Порушиш його в коді — заплатиш міграцією.
Повна схема: 11 таблиць Notes Platform¶
schema_v1 · 11 tables · 1 junction table · postgres-ready
─────────────────────────────────────────────────────────
USER (auth_user) USER_PROFILE ←── 1:1 OneToOne
┌──────────────┬─────────────┐ ┌────────────────┬──────────────────┐
│ id │ bigint PK │──┐ │ id │ bigint PK │
│ username │ varchar(150)│ └──►│ user_id │ bigint UNIQUE FK │
│ email │ varchar(254)│ │ display_name │ varchar(60) │
│ password │ varchar(128)│ │ timezone │ varchar(40) │
│ date_joined │ timestamp │ │ avatar_url │ text │
└──────────────┴─────────────┘ └────────────────┴──────────────────┘
│ owns 1:N │ writes 1:N │ owns tags 1:N
▼ ▼ ▼
NOTEBOOK NOTE (центральна) TAG
┌─────────────┬──────────┐ ┌───────────────┬──────────────────┐ ┌──────────┬───────────────────┐
│ id │ bigint PK│ │ id │ bigint PK │ │ id │ bigint PK │
│ user_id │ bigint FK│ │ user_id │ bigint FK │ │ user_id │ bigint FK │
│ title │ varchar │ │ notebook_id │ bigint NULL FK │ │ name │ varchar(50) │
│ color │ varchar │ │ title │ varchar(200) │ │ color │ varchar(7) │
│ is_default │ boolean │ │ content │ text │ └──────────┴───────────────────┘
└─────────────┴──────────┘ │ priority │ smallint │ unique_together(user_id, name)
│ contains 1:N │ is_pinned │ boolean │
└─────────────────────►│ is_archived │ boolean │◄──── M:N через NOTE_TAGS
│ created_at │ timestamp │
│ updated_at │ timestamp │
│ views_count │ integer │
└───────────────┴──────────────────┘
│ reminds 1:N
▼
REMINDER NOTE_TAGS (junction)
┌───────────┬──────────────┐ ┌──────────┬────────────┐
│ id │ bigint PK │ │ note_id │ bigint FK │
│ note_id │ bigint FK │ │ tag_id │ bigint FK │
│ remind_at │ timestamp │ └──────────┴────────────┘
│ is_sent │ boolean │ UNIQUE(note_id, tag_id)
│ repeat │ varchar(20) │ Django створює авто
└───────────┴──────────────┘
TODO_LIST TODO_ITEM
┌─────────────┬──────────┐ ┌────────────────┬──────────────┐
│ id │ bigint PK│ │ id │ bigint PK │
│ user_id │ bigint FK│───►│ todo_list_id │ bigint FK │
│ title │ varchar │ │ text │ varchar(500) │
│ is_completed│ boolean │ │ is_done │ boolean │
└─────────────┴──────────┘ │ order_position │ integer │
│ due_date │ date NULL │
└────────────────┴──────────────┘
SHOPPING_LIST SHOP_ITEM
┌─────────────┬──────────┐ ┌──────────────────┬──────────────────┐
│ id │ bigint PK│ │ id │ bigint PK │
│ user_id │ bigint FK│───►│ shopping_list_id │ bigint FK │
│ title │ varchar │ │ name │ varchar(120) │
│ store_name │ varchar │ │ quantity │ decimal(8,2) │
└─────────────┴──────────┘ │ unit │ varchar(20) │
│ is_purchased │ boolean │
│ estimated_price │ decimal(10,2) │
└──────────────────┴──────────────────┘
⚠️ DecimalField для грошей — не FloatField!
erDiagram
USER ||--o| USER_PROFILE : "1:1 has profile"
USER ||--o{ NOTEBOOK : "1:N owns"
USER ||--o{ NOTE : "1:N writes"
USER ||--o{ TAG : "1:N owns tags"
USER ||--o{ TODO_LIST : "1:N owns"
USER ||--o{ SHOPPING_LIST : "1:N owns"
NOTEBOOK ||--o{ NOTE : "1:N contains (SET_NULL)"
NOTE }o--o{ TAG : "M:N tagged with"
NOTE ||--o{ REMINDER : "1:N reminds (CASCADE)"
TODO_LIST ||--o{ TODO_ITEM : "1:N has items"
SHOPPING_LIST ||--o{ SHOP_ITEM : "1:N has items"
02 · RELATIONSHIP TYPES¶
Три типи зв'язків між таблицями. Питання «де живе FK?» — це не технічне, це архітектурне.
1:1 — OneToOneField¶
FK + UNIQUE · "user has exactly one profile"
USER USER_PROFILE
┌──────────────┬──────────┐ ┌────────────────┬──────────────────┐
│ id bigint PK│ │──►│ id bigint PK│ │
│ username │ │ │ user_id bigint │ UNIQUE FK │
└──────────────┴──────────┘ │ display varchar│ │
│ timezone │ │
└────────────────┴──────────────────┘
class UserProfile(models.Model):
user = models.OneToOneField(
User, on_delete=CASCADE,
related_name='profile',
)
Чому це 1:1? Звичайний
FK+UNIQUEна колонці.
Реверс — атрибут (не QuerySet):user.profile.
Корисно для вертикального партиціонування: 5 базових полів у User, 20 опціональних у Profile — не забруднюємо головну таблицю.
1:N — ForeignKey¶
FK на "Many" стороні · "notebook has many notes"
NOTEBOOK NOTE NOTE NOTE
┌──────────┬──────────┐ ┌───────────────────────┐
│ id │ bigint PK│──┬─►│ id bigint PK │
│ title │ varchar │ │ │ notebook_id bigint FK │← FK тут!
└──────────┴──────────┘ ├─►│ title varchar │
│ └───────────────────────┘
├─► (ще одна нотатка)
└─► (ще одна нотатка)
class Note(models.Model):
notebook = models.ForeignKey(
Notebook, on_delete=SET_NULL,
null=True, related_name='notes',
)
Де живе FK? Завжди на Many-стороні.
Notebookне зберігає список нотаток —Noteзберігаєnotebook_id.
Це фізика SQL: один стовпець = одне значення.
M:N — ManyToManyField¶
Через junction table · Django створює автоматично
NOTE NOTE_TAGS (junction) TAG
┌──────────┬──────┐ ┌──────────┬──────────┐ ┌──────────┬──────────┐
│ id │ PK │──►│ note_id │ bigint FK│ │ id │ PK │
│ title │ │ │ tag_id │ bigint FK│─►│ name │ varchar │
└──────────┴──────┘ └──────────┴──────────┘ └──────────┴──────────┘
UNIQUE(note_id, tag_id)
Django: hello_app_note_tags
class Note(models.Model):
tags = models.ManyToManyField(Tag, blank=True)
# Django САМ створює: hello_app_note_tags(note_id, tag_id)
note.tags.add(tag_py, tag_dj) # INSERT × 2 у junction
tag_py.notes.all() # reverse → SELECT JOIN
Junction = окрема таблиця. Тут немає чарів — це звичайна таблиця з двома FK.
Хочеш додатиadded_atабоroleдо зв'язку? Передайthrough=і опиши свою junction модель.
on_delete — що відбувається при видаленні батька?¶
⚠️ Помилка: дефолт CASCADE для всього → одне видалення користувача може стерти сотні рядків.
Завжди свідомо вибирай стратегію. Це бізнес-рішення, не технічне.
| Стратегія | Що робить | Коли вибирати |
|---|---|---|
CASCADE |
Видалити дочірні автоматично | Reminder → Note: нагадування без нотатки не має сенсу |
SET_NULL |
FK → NULL, дочірні живуть далі | Note → Notebook: нотатка існує без записника. Поле має бути null=True |
SET_DEFAULT |
FK → значення default |
Note → Notebook: перенести в «Default» записник |
PROTECT |
ProtectedError, видалення заборонене |
Tag: не дозволити видаляти тег поки він використовується |
RESTRICT |
Заборонити, але дозволити каскад з вищого рівня | Складні домени з кількома рівнями власності |
DO_NOTHING |
БД сама вирішує (часто = orphan) | Майже ніколи. Тільки якщо керуєш consistency вручну (triggers) |
03 · APPLICATION LAYERS¶
Thin Views · Selectors Read · Services Write
Кожен шар має одну відповідальність. Порушення цього правила — головна причина "fat view" антипатерну.
Архітектура шарів¶
HTTP REQUEST
│
▼
┌─────────────────────────────────────────┐
│ views.py │
│ ✓ парсить request │
│ ✓ кличе selector / service │
│ ✓ render / redirect │
│ ✗ жодних QuerySet │
└────────────┬───────────────┬────────────┘
│ READ → │ WRITE →
▼ ▼
┌────────────────────┐ ┌──────────────────────────────┐
│ selectors.py │ │ services.py │
│ SELECT тільки │ │ CREATE / UPDATE / DELETE │
│ │ │ │
│ get_user_notes() │ │ create_note() │
│ get_note_detail() │ │ update_note() │
│ get_notebooks() │ │ toggle_todo_item() │
│ get_reminders() │ │ mark_reminder_sent() │
│ │ │ │
│ select_related, │ │ transaction.atomic, │
│ prefetch_related, │ │ F(), select_for_update │
│ annotate — тут │ │ — тут │
└────────┬───────────┘ └───────────┬──────────────────┘
│ SELECT │ INSERT/UPDATE/DELETE
└──────────┬───────────────┘
▼
┌─────────────┐
│ PostgreSQL │
│ Database │
└─────────────┘
Чому розділяти? Один і той самий запит потрібен у View, Celery task, API endpoint, тесті.
Безselectors.py— копіюєш QuerySet у 10 місцях. Зміниш логіку → 10 місць оновлювати.
Зselectors.py→ одна функція, всі використовують.
Матриця відповідальності¶
| Шар | ✓ Робить | ✗ Не робить |
|---|---|---|
models.py |
Структура · поля · зв'язки · constraints · indexes | Бізнес-логіка, HTTP |
selectors.py |
SELECT QuerySets · annotate · prefetch · фільтри | INSERT / UPDATE / DELETE |
services.py |
CREATE / UPDATE / DELETE · transaction · F() · locks | HTTP, render, redirect |
views.py |
Парсити request · викликати selector/service · render | QuerySet, бізнес-логіка |
forms.py |
Валідація вводу з HTTP · clean_* |
БД запити (крім queryset для choices) |
templates/ |
HTML розмітка · context з View | Будь-яка логіка |
Перевір себе: якщо в
views.pyбачишNote.objects.filter(...)— це сигнал перенести уselectors.py.
Якщо в шаблоні бачиш умову перевірки прав доступу — це теж порушення.
READ flow — трасування GET /notes/?q=python¶
01 │ Browser │ GET /notes/?q=python
02 │ urls.py │ match → views.note_list
03 │ views.py │ search = request.GET['q']
04 │ views.py │ selectors.get_user_notes(user, search='python')
05 │ selectors.py │ QuerySet.filter().select_related('notebook').prefetch_related('tags')
06 │ ORM │ SQLCompiler → 2 SQL (note + tags prefetch)
07 │ PostgreSQL │ виконує план, повертає rows
08 │ ORM │ ModelIterable → list[Note] з заповненим _result_cache
09 │ views.py │ render('note_list.html', {notes, tags, notebooks})
10 │ Browser │ 200 OK · HTML
View залишається тонкою: 3 рядки коду (parse + delegate + render).
Уся складність — уselectors.get_user_notes: фільтри, JOIN-и, кеш prefetch.
WRITE flow — трасування POST /notes/create/¶
01 │ Browser │ POST /notes/create/ form=...
02 │ views.py │ form = NoteForm(POST, user=request.user) · is_valid?
03 │ views.py │ services.create_note(user, title, tag_ids)
04 │ services.py │ with transaction.atomic():
05 │ services.py │ Note.objects.create(...) → BEGIN, INSERT note
06 │ services.py │ Tag.objects.filter(user=user, id__in=...) · перевірка ownership
07 │ services.py │ note.tags.set(valid) → INSERT × N у junction
08 │ PostgreSQL │ COMMIT · on_commit callbacks
09 │ views.py │ messages.success(...) · return redirect('note_detail', pk)
10 │ Browser │ 302 → GET /notes/42/ (PRG pattern: F5 безпечний)
PRG (Post / Redirect / Get). POST не рендерить — він робить redirect.
F5 повторює GET — безпечно. Без PRG: F5 = дубль запису.Ownership через FK traversal.
Tag.objects.filter(user=user, id__in=...)—
не дай юзеру прикріпити чужі теги до своєї нотатки.
04 · NORMALIZATION¶
"Одна правда для одного факту."
Нормалізація — це процес виявлення і усунення дублювання з реляційної схеми.
Мета: кожен факт зберігається рівно в одному місці.
Before / After — 3NF на прикладі¶
❌ ДО (1NF) — все в одній таблиці employees:
| id | name | dept_id | dept_name | location | salary |
|---|---|---|---|---|---|
| 1 | Alice | 10 | Engineering | Kyiv | 50000 |
| 2 | Bob | 10 | Engineering | Kyiv | 45000 |
| 3 | Carol | 20 | Design | Lviv | 48000 |
| 4 | Dan | 10 | Engineering | Kyiv | 52000 |
| 5 | Eva | 20 | Design | Lviv | 49000 |
Проблема: "Engineering / Kyiv" повторюється 3 рази.
Переїхали в Харків? UPDATE 1000 рядків. Помилився в назві? Inconsistency.
--- → 3NF: винести в окрему таблицю ---
✅ ПІСЛЯ — employees (FK → departments.id):
| id | name | dept_id | salary |
|---|---|---|---|
| 1 | Alice | 10 | 50000 |
| 2 | Bob | 10 | 45000 |
| 3 | Carol | 20 | 48000 |
| 4 | Dan | 10 | 52000 |
| 5 | Eva | 20 | 49000 |
✅ departments — одна правда:
| id | name | location |
|---|---|---|
| 10 | Engineering | Kyiv |
| 20 | Design | Lviv |
# Django модель після нормалізації:
class Department(models.Model):
name = models.CharField(max_length=80)
location = models.CharField(max_length=80)
class Employee(models.Model):
name = models.CharField(max_length=80)
department = models.ForeignKey(Department, on_delete=models.PROTECT)
salary = models.DecimalField(max_digits=10, decimal_places=2)
Переїзд тепер:
UPDATE departments SET location='Kharkiv' WHERE id=10;
1 рядок замість 1000. Неможлива inconsistency.
Три нормальні форми — шпаргалка¶
| Форма | Правило | Порушення — приклад |
|---|---|---|
| 1NF | Всі значення атомарні, немає масивів у клітинці | tags = "python,django,orm" у одному стовпці |
| 2NF | Всі неключові атрибути залежать від цілого PK | В таблиці (order_id, product_id) → product_name залежить тільки від product_id |
| 3NF | Немає транзитивних залежностей (A→B→C де B не ключ) | employee.dept_name залежить від dept_id, а не від employee.id |
Практичне правило: якщо при зміні одного факту треба
UPDATE> 1 рядка — схема не нормалізована.
05 · MIGRATIONS¶
"Git для схеми бази даних."
Кожна міграція — коміт що описує зміни схеми. Django відстежує які вже виконані уdjango_migrations.
Migration Lifecycle — від зміни моделі до ALTER TABLE¶
┌─────────────┐ ┌─────────────────────────────────┐ ┌──────────────────┐
│ Developer │ │ Django ORM │ │ PostgreSQL │
└──────┬──────┘ └──────────────┬──────────────────┘ └────────┬─────────┘
│ │ │
1. Зміни в models.py │ │
│ │ │
2. $ makemigrations ──────────►│ │
│ Порівнює │ │
│ models.py │ │
│ з останнім │ │
│◄─────────────────────── │ │
3. Отримує 0003_add_field.py │ │
│ │ │
4. $ sqlmigrate (перевірка) │ │
│ │ │
5. $ migrate ─────────────────►│ │
│ │ ALTER TABLE notes_note ────────►│
│ │ ADD COLUMN word_count integer; │
│ │◄──────────────────────────────── │
│ │ INSERT INTO django_migrations │
│◄─────────────────────── │ (app, name, applied) │
6. ✓ Applied 0003_add_field │ │
Migration Dependency Graph (DAG)¶
graph TD
A["auth.0001_initial<br/>CREATE TABLE auth_user"] --> C
B["hello_app.0001_initial<br/>CREATE TABLE note"] --> C
C["hello_app.0002_extended<br/>CREATE notebooks, tags…"]
C --> D["hello_app.0003_add_index<br/>CREATE INDEX"]
C --> E["hello_app.0004_add_word_count<br/>ADD COLUMN word_count"]
D --> F["hello_app.0005_constraint<br/>ADD CONSTRAINT CHECK"]
E --> F
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#f1f8e9,stroke:#2e7d32
style C fill:#f1f8e9,stroke:#2e7d32
style D fill:#f1f8e9,stroke:#2e7d32
style E fill:#f1f8e9,stroke:#2e7d32
style F fill:#f1f8e9,stroke:#2e7d32
django_migrations— спеціальна таблиця-журнал.migrateзастосовує тільки ті вузли,
яких там ще немає, у топологічному порядку залежностей.
Ключові команди¶
# Генерувати міграцію з описовою назвою
python manage.py makemigrations --name add_word_count_to_note
# Переглянути SQL до виконання (завжди робити перед migrate!)
python manage.py sqlmigrate hello_app 0003
# Застосувати всі нові міграції
python manage.py migrate
# Показати стан усіх міграцій (✓ виконано, □ ще ні)
python manage.py showmigrations
# Відкотити до конкретної міграції
python manage.py migrate hello_app 0002
Правила міграцій¶
| Правило | Чому |
|---|---|
✅ Нове поле: завжди default= або null=True |
Без дефолту — що записати в існуючі рядки? |
✅ Описова назва --name add_word_count |
0003_add_word_count читабельніше за 0003_note |
| ✅ Комітити міграції в git | Інакше колеги не зможуть оновити свою БД |
✅ sqlmigrate перед migrate |
Перевіряєш SQL що буде виконано |
| ❌ Редагувати виконану міграцію | Production і dev розсинхронізуються — катастрофа |
| ❌ Видаляти міграції з git | Руйнує граф залежностей |
Крок 0 — Від домену до схеми¶
🧠 Ментальна модель: Проектування бази даних — це не "яку таблицю додати". Це моделювання реального світу. Спочатку думаєш мовою бізнесу: "користувач може мати записники, кожен записник містить нотатки, нотатки можуть мати теги". Потім переводиш це в структури даних. Django моделі — це останній крок, а не перший.
📚 Де починаються помилки: Більшість проблем зі схемою БД виникають тому що розробник одразу пише код, не думаючи про зв'язки. Через місяць виявляється що потрібен новий зв'язок — а додати його без масивної міграції неможливо. 30 хвилин на проектування = тижні зекономленого часу.
❌ Типова помилка: Починати з написання моделі. Правильний порядок: домен → сутності → атрибути → зв'язки → нормалізація → Django модель.
Процес проектування (правильний порядок):¶
1. ДОМЕН — описати словами що будуємо
"Користувач має записники. Записники містять нотатки.
Нотатки можуть бути в списках справ. Є нагадування з датою."
2. СУТНОСТІ — знайти "іменники" в описі домену
Користувач, Записник, Нотатка, Список справ, Пункт списку,
Нагадування, Список покупок, Товар, Тег
3. АТРИБУТИ — для кожної сутності: що ми знаємо про неї?
Нотатка: title, content, created_at, is_pinned, priority
4. ЗВ'ЯЗКИ — як сутності пов'язані одна з одною?
Нотатка → Записник: багато нотаток в одному записнику (FK)
Нотатка ↔ Тег: одна нотатка, багато тегів; один тег, багато нотаток (M:N)
5. НОРМАЛІЗАЦІЯ — перевірити: чи є дублювання?
Якщо в Нотатці є поле author_name — це дублювання з User. Замінити на FK.
6. ВИБІР on_delete — що відбувається при видаленні батьківського запису?
Видалення Записника → видалити всі нотатки (CASCADE)?
Або перенести їх в "Загальний записник" (SET_DEFAULT)?
7. DJANGO МОДЕЛЬ — тільки після всього вище
Крок 1 — Проектуємо домен¶
Ми будуємо персональний менеджер записів — як Notion або Evernote, але спрощений.
Що повинен вміти наш продукт:¶
| Функція | Опис |
|---|---|
| 📓 Записники | Колекції нотаток (як папки). Кожна нотатка в одному записнику |
| 📝 Нотатки | Текстові записи з заголовком, вмістом, тегами, пріоритетом |
| 🏷️ Теги | Мітки для нотаток. Одна нотатка = багато тегів. Один тег = багато нотаток |
| ✅ Списки справ | To-do list. Кожен список має пункти з галочкою |
| 🛒 Списки покупок | Список товарів з кількістю та чи куплено |
| ⏰ Нагадування | Повідомлення прив'язані до нотатки або дати |
| 📌 Пінінг | Нотатки можна закріпити зверху |
Крок 2 — Сутності та зв'язки¶
Аналіз зв'язків по типах:¶
ONE-TO-ONE (1:1) — "один має рівно одного":
User ←→ UserProfile
(кожен користувач має один профіль, один профіль у одного користувача)
ONE-TO-MANY (1:N) — "один має багато":
User → Notebook (один користувач, багато записників)
Notebook → Note (один записник, багато нотаток)
User → Note (нотатка має автора)
User → TodoList (один користувач, багато списків справ)
TodoList → TodoItem (один список, багато пунктів)
User → ShoppingList (один користувач, багато списків покупок)
ShoppingList → ShopItem (один список, багато товарів)
Note → Reminder (одна нотатка може мати кілька нагадувань)
MANY-TO-MANY (M:N) — "багато пов'язані з багатьма":
Note ↔ Tag (нотатка має теги, тег є у багатьох нотатках)
Де живе FK — правило "Many side":¶
Notebook → Note (1:N):
notebooks: notes:
┌────┬──────────┐ ┌────┬─────────────┬─────────────┐
│ id │ title │ │ id │ title │ notebook_id │ ← FK тут
├────┼──────────┤ ├────┼─────────────┼─────────────┤
│ 1 │ Робота │◄───────────│ 1 │ Django ORM │ 1 │
│ 2 │ Особисте │◄───────────│ 2 │ Python tips │ 1 │
│ │ │◄───────────│ 3 │ Рецепт борщу │ 2 │
└────┴──────────┘ └────┴─────────────┴─────────────┘
Notebook НЕ зберігає список нотаток.
Note зберігає ПОСИЛАННЯ на свій записник (notebook_id=1).
Крок 3 — ER-діаграми¶
Головна ER-діаграма¶
erDiagram
USER ||--o| USER_PROFILE : "has profile"
USER ||--o{ NOTEBOOK : "owns"
USER ||--o{ NOTE : "writes"
USER ||--o{ TODO_LIST : "owns"
USER ||--o{ SHOPPING_LIST : "owns"
NOTEBOOK ||--o{ NOTE : "contains"
NOTE }o--o{ TAG : "tagged with"
NOTE ||--o{ REMINDER : "reminds"
TODO_LIST ||--o{ TODO_ITEM : "has items"
SHOPPING_LIST ||--o{ SHOP_ITEM : "has items"
USER {
bigint id PK
string username
string email
}
NOTE {
bigint id PK
bigint user_id FK
bigint notebook_id FK
string title
text content
int priority
boolean is_pinned
timestamp updated_at
}
NOTEBOOK {
bigint id PK
bigint user_id FK
string title
string color
boolean is_default
}
TAG {
bigint id PK
bigint user_id FK
string name
string color
}
REMINDER {
bigint id PK
bigint note_id FK
timestamp remind_at
boolean is_sent
}
TODO_LIST {
bigint id PK
bigint user_id FK
string title
}
TODO_ITEM {
bigint id PK
bigint todo_list_id FK
string text
boolean is_done
int order_position
}
SHOPPING_LIST {
bigint id PK
bigint user_id FK
string title
}
SHOP_ITEM {
bigint id PK
bigint shopping_list_id FK
string name
decimal quantity
boolean is_purchased
decimal estimated_price
}
Flowchart: процес проектування моделей¶
flowchart TD
A[Описати домен словами] --> B[Знайти сутності\nіменники у тексті]
B --> C[Визначити атрибути\nкожної сутності]
C --> D{Зв'язок між\nсутностями?}
D -->|"1:1"| E[OneToOneField\nFK + UNIQUE]
D -->|"1:N"| F[ForeignKey\nна стороні Many]
D -->|"M:N"| G[ManyToManyField\nDjango junction table]
E --> H[Вибрати on_delete]
F --> H
G --> I[Django створить\njunction table]
H --> J[Нормалізація:\nприбрати дублювання]
J --> K[Написати Django модель]
Крок 4 — Django Моделі¶
Детальний код моделей у hello_app/models.py. Ось ключові принципи:
# Короткий огляд всіх моделей і їх зв'язків
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=CASCADE, related_name='profile')
# OneToOne = FK + UNIQUE. Кожен User → максимум один Profile.
class Tag(models.Model):
user = models.ForeignKey(User, on_delete=CASCADE)
name = models.CharField(max_length=50)
# Теги прив'язані до конкретного user (privacy!)
class Meta:
unique_together = [('user', 'name')] # Alice і Bob можуть мати однаковий тег
class Notebook(models.Model):
user = models.ForeignKey(User, on_delete=CASCADE, related_name='notebooks')
title = models.CharField(max_length=100)
is_default = models.BooleanField(default=False)
class Note(models.Model):
user = models.ForeignKey(User, on_delete=CASCADE, related_name='notes')
notebook = models.ForeignKey(Notebook, on_delete=SET_NULL, null=True, blank=True)
tags = models.ManyToManyField(Tag, blank=True)
# Три типи зв'язків в одній моделі:
# user: FK (обов'язковий, CASCADE)
# notebook: FK (необов'язковий, SET_NULL — нотатка може існувати без записника)
# tags: M:N (Django автоматично створює junction table)
priority = models.PositiveSmallIntegerField(default=1)
is_pinned = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
class Reminder(models.Model):
note = models.ForeignKey(Note, on_delete=CASCADE, related_name='reminders')
remind_at = models.DateTimeField()
is_sent = models.BooleanField(default=False)
class TodoList(models.Model):
user = models.ForeignKey(User, on_delete=CASCADE, related_name='todo_lists')
class TodoItem(models.Model):
todo_list = models.ForeignKey(TodoList, on_delete=CASCADE, related_name='items')
text = models.CharField(max_length=500)
is_done = models.BooleanField(default=False)
order_position = models.PositiveIntegerField(default=0)
class ShoppingList(models.Model):
user = models.ForeignKey(User, on_delete=CASCADE, related_name='shopping_lists')
class ShopItem(models.Model):
shopping_list = models.ForeignKey(ShoppingList, on_delete=CASCADE, related_name='items')
quantity = models.DecimalField(max_digits=8, decimal_places=2, default=1)
# DecimalField для грошей і кількості! FloatField → 0.1+0.2 = 0.30000000000000004
estimated_price = models.DecimalField(max_digits=10, decimal_places=2, null=True)
Крок 5 — Міграції¶
🧠 Ментальна модель: Міграції — це Git для схеми бази даних. Кожна міграція — це "коміт" що описує зміни схеми. Django відстежує які міграції вже виконані в таблиці
django_migrations.migrateвиконує тільки нові.❌ Типова помилка: Редагувати вже застосовану міграцію. Якщо міграція вже застосована на production і ти її редагуєш — production і development розсинхронізуються. Зміна застосованої міграції = катастрофа. Якщо потрібна зміна → нова міграція.
Workflow міграцій — покроково:¶
sequenceDiagram
participant Dev as Developer
participant Django as Django
participant DB as Database
Dev->>Django: Змінюємо models.py
Dev->>Django: python manage.py makemigrations
Django->>Django: Порівнює models.py з останнім станом міграцій
Django-->>Dev: Генерує 0002_extended_models.py
Dev->>Django: python manage.py migrate
Django->>DB: ALTER TABLE / CREATE TABLE (DDL)
DB-->>Django: OK
Django->>DB: INSERT INTO django_migrations (app, name)
Django-->>Dev: Applied 0002_extended_models
# Кроки для нашого проекту:
# 1. Переходимо до папки проекту
cd module_5/lesson_Django_ORM_Database/notes_project
# 2. Перевіряємо що hello_app в INSTALLED_APPS
# settings.py → INSTALLED_APPS має містити 'hello_app'
# 3. Генеруємо першу міграцію
python manage.py makemigrations
# Очікуваний вивід:
# Migrations for 'hello_app':
# hello_app/migrations/0001_initial.py
# - Create model UserProfile, Tag, Notebook, Note, Reminder,
# TodoList, TodoItem, ShoppingList, ShopItem
# 4. Переглядаємо SQL що буде виконано (без виконання)
python manage.py sqlmigrate hello_app 0001
# Корисно: перевірити типи стовпців, FK, індекси
# 5. Виконуємо міграцію
python manage.py migrate
# 6. Показати стан всіх міграцій
python manage.py showmigrations
Коли потрібна нова міграція (додаємо поле):¶
# Додаємо нове поле до Note
class Note(models.Model):
...
word_count = models.PositiveIntegerField(default=0) # НОВЕ ПОЛЕ
python manage.py makemigrations --name add_word_count_to_note
# → hello_app/migrations/0003_add_word_count_to_note.py
python manage.py sqlmigrate hello_app 0003
# ALTER TABLE "hello_app_note" ADD COLUMN "word_count" integer NOT NULL DEFAULT 0;
python manage.py migrate
| Правило | Чому |
|---|---|
Нове поле: завжди default= або null=True |
Без дефолту — що записати в існуючі рядки? |
| Ніколи не редагувати виконану міграцію | Production і dev розсинхронізуються |
Описова назва (add_word_count) |
0003_add_word_count > 0003_note |
| Комітити міграції в git | Інакше колеги не зможуть оновити свою БД |
Крок 6 — SQLite запуск¶
SQLite — ідеальний для розробки. Нічого не встановлювати.
# Ініціалізуємо БД (SQLite за замовчуванням)
python manage.py migrate
# Створюємо суперюзера для адмінки
python manage.py createsuperuser
# Запускаємо сервер
python manage.py runserver
# http://127.0.0.1:8000/
# http://127.0.0.1:8000/admin/
# http://127.0.0.1:8000/notes/
Тестуємо моделі через Django shell:¶
from django.contrib.auth.models import User
from hello_app.models import *
# Створюємо користувача
user = User.objects.create_user('alice', 'alice@test.com', 'pass123')
# Профіль (OneToOne)
profile = UserProfile.objects.create(user=user, display_name='Alice')
print(user.profile.display_name) # через related_name
# Записник
notebook = Notebook.objects.create(user=user, title='Робота', is_default=True)
# Теги
tag_py = Tag.objects.create(user=user, name='python', color='#3776AB')
tag_dj = Tag.objects.create(user=user, name='django', color='#092E20')
# Нотатка з тегами
note = Note.objects.create(
user=user, notebook=notebook,
title='Django ORM', content='QuerySet — лінива оцінка...',
priority=Note.PRIORITY_HIGH, is_pinned=True
)
note.tags.add(tag_py, tag_dj) # M:N — INSERT у junction table
# Список справ
todo = TodoList.objects.create(user=user, title='Вивчити ORM')
TodoItem.objects.bulk_create([
TodoItem(todo_list=todo, text='RELATIONAL_DB_FOUNDATIONS.md', order_position=1),
TodoItem(todo_list=todo, text='DJANGO_ORM_DEEP.md', order_position=2),
TodoItem(todo_list=todo, text='Зробити проект', order_position=3),
])
# Список покупок
shopping = ShoppingList.objects.create(user=user, title='В магазин')
ShopItem.objects.create(
shopping_list=shopping, name='Молоко',
quantity=2, unit=ShopItem.UNIT_LITER, estimated_price=45.00
)
print(f"Нотаток: {user.notes.count()}")
print(f"Теги: {[t.name for t in note.tags.all()]}")
Крок 7 — Архітектура: Services & Selectors¶
Детальна документація (читати окремо): - DJANGO_SERVICES_SELECTORS.md — повна архітектура: View→Service→Selector→ORM, data flow, антипатерни, таблиця відповідальностей - DJANGO_SELECTORS.md — глибоко про Selector: CQRS-light, naming convention, QuerySet vs evaluated list, централізація N+1 - DJANGO_SERVICES.md — глибоко про Service: stateless функції,
@transaction.atomic,on_commit(), Celery як transport layer
🧠 Ментальна модель: View — це контролер трафіку, не виконавець. Він приймає HTTP-запит, делегує роботу і повертає відповідь. Якщо View сам виконує всі запити до БД — це "fat view" і через місяць код стає нечитабельним. Selector = читання. Service = запис/зміна.
📚 Чому Services/Selectors: Один і той самий запит до БД потрібен у View, в Celery task, в API, в тесті. Без Selector — копіюєш QuerySet у 10 місцях. Зміниш бізнес-логіку → 10 місць оновлювати. З Selector → 1 функція.
🌐 Архітектурний поділ: -
selectors.py— ТІЛЬКИ читання (SELECT). Немає INSERT/UPDATE/DELETE. -services.py— ТІЛЬКИ запис (CREATE/UPDATE/DELETE). Немає прямих SELECT в View. -views.py— ТІЛЬКИ HTTP логіка. Викликає selectors і services.❌ Типова помилка:
Note.objects.filter(user=user)прямо у View — це порушення. Всі QuerySets → у selectors.
Файл hello_app/selectors.py:¶
# hello_app/selectors.py
"""
Selectors — шар ЧИТАННЯ даних.
Тут живуть всі SELECT запити.
View, Tasks, API — всі використовують selectors.
"""
from django.db.models import Count, Q, Prefetch
from django.utils import timezone
from .models import Note, Notebook, Tag, TodoList, TodoItem, ShoppingList, Reminder
def get_user_notes(user, *, archived=False, notebook=None, tag=None, search=None):
"""
Список нотаток користувача з фільтрами.
select_related + prefetch_related — вирішує N+1 проблему.
"""
qs = Note.objects.filter(
user=user,
is_archived=archived
).select_related(
'notebook' # FK → JOIN (1 запит замість N)
).prefetch_related(
'tags' # M:N → 2-й запит (не N+1)
)
if notebook:
qs = qs.filter(notebook=notebook)
if tag:
qs = qs.filter(tags=tag)
if search:
qs = qs.filter(
Q(title__icontains=search) | Q(content__icontains=search)
)
return qs.order_by('-is_pinned', '-priority', '-updated_at')
def get_note_detail(user, note_id):
"""
Деталь нотатки — з усіма зв'язками.
Raises Note.DoesNotExist якщо не знайдено або не власник.
"""
return Note.objects.select_related(
'notebook', 'user'
).prefetch_related(
'tags',
Prefetch(
'reminders',
queryset=Reminder.objects.filter(
remind_at__gte=timezone.now()
).order_by('remind_at')
)
).get(id=note_id, user=user)
def get_user_notebooks(user):
"""Всі записники з кількістю нотаток."""
return Notebook.objects.filter(user=user).annotate(
note_count=Count('notes', filter=Q(notes__is_archived=False))
).order_by('-is_default', 'title')
def get_user_tags(user):
"""Теги з кількістю нотаток."""
return Tag.objects.filter(user=user).annotate(
note_count=Count('notes')
).order_by('name')
def get_user_todo_lists(user):
"""Списки справ з прогресом."""
return TodoList.objects.filter(
user=user
).annotate(
total_items=Count('items'),
done_items=Count('items', filter=Q(items__is_done=True))
).prefetch_related('items').order_by('is_completed', '-created_at')
def get_pending_reminders():
"""Для Celery worker: нагадування які треба відправити."""
return Reminder.objects.filter(
is_sent=False,
remind_at__lte=timezone.now()
).select_related('note__user')
Файл hello_app/services.py:¶
# hello_app/services.py
"""
Services — шар ЗАПИСУ даних.
Тут живе вся бізнес-логіка: CREATE, UPDATE, DELETE.
Services можуть викликати selectors але не навпаки.
"""
from django.db import transaction
from django.db.models import F
from django.utils.text import slugify
from .models import Note, Notebook, Tag, TodoItem, Reminder
def create_note(*, user, title, content='', notebook=None, priority=1, tag_ids=None):
"""
Створення нотатки з тегами.
transaction.atomic() — або Note і теги зберігаються разом, або нічого.
"""
with transaction.atomic():
note = Note.objects.create(
user=user,
title=title,
content=content,
notebook=notebook,
priority=priority,
)
if tag_ids:
# Перевіряємо що теги належать цьому user (безпека!)
valid_tags = Tag.objects.filter(id__in=tag_ids, user=user)
note.tags.set(valid_tags)
return note
def update_note(note, *, title=None, content=None, priority=None,
notebook=None, is_pinned=None, tag_ids=None):
"""
Оновлення нотатки — тільки вказані поля.
update_fields → генерує UPDATE тільки для змінених стовпців.
"""
changed_fields = []
if title is not None:
note.title = title
changed_fields.append('title')
if content is not None:
note.content = content
changed_fields.append('content')
if priority is not None:
note.priority = priority
changed_fields.append('priority')
if notebook is not None:
note.notebook = notebook
changed_fields.append('notebook')
if is_pinned is not None:
note.is_pinned = is_pinned
changed_fields.append('is_pinned')
if changed_fields:
note.save(update_fields=changed_fields)
if tag_ids is not None:
valid_tags = Tag.objects.filter(id__in=tag_ids, user=note.user)
note.tags.set(valid_tags)
return note
def archive_note(note):
"""Архівування нотатки (не видалення)."""
Note.objects.filter(pk=note.pk).update(is_archived=True)
def delete_note(note):
"""Видалення нотатки. CASCADE видалить Reminders автоматично."""
note.delete()
def toggle_todo_item(item_id, *, user):
"""
Відмітити/зняти відмітку пункту списку.
select_for_update() — запобігає race condition при одночасних кліках.
"""
with transaction.atomic():
item = TodoItem.objects.select_for_update().get(
id=item_id,
todo_list__user=user # перевірка права доступу через FK traversal
)
item.is_done = not item.is_done
item.save(update_fields=['is_done'])
return item
def mark_reminder_sent(reminder_id):
"""
Атомарно позначаємо нагадування як відправлене.
F() + update() — race condition safe.
"""
Reminder.objects.filter(pk=reminder_id).update(is_sent=True)
def create_tag(*, user, name, color='#808080'):
"""Створення тегу з перевіркою унікальності."""
tag, created = Tag.objects.get_or_create(
user=user,
name=name.lower().strip(),
defaults={'color': color}
)
return tag, created
Крок 8 — Views та шаблони¶
🧠 Ментальна модель: View — це тонкий шар. Він: (1) парсить HTTP запит, (2) викликає selector або service, (3) рендерить шаблон або повертає redirect. Жодної бізнес-логіки, жодних QuerySets прямо у View.
📚 PRG патерн: Post/Redirect/Get — стандарт для форм. GET → показати форму. POST → обробити → redirect (не render). Чому? Без redirect: користувач натискає F5 → браузер повторює POST → дублікат запису. З redirect → F5 повторює GET → безпечно.
❌ Типова помилка:
Note.objects.filter(...)прямо у View. Це порушення архітектурного поділу. Через 3 місяці той самий запит потрібен у Celery task → копіюєш → дублювання → розсинхронізація.
hello_app/views.py:¶
# hello_app/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from .models import Note
from .forms import NoteForm
from . import selectors, services
@login_required
def note_list(request):
"""
Список нотаток поточного користувача.
View тільки: отримує фільтри з GET → викликає selector → рендерить.
"""
search = request.GET.get('q', '')
tag_id = request.GET.get('tag')
notebook_id = request.GET.get('notebook')
# Selector вирішує ЯКИЙ запит, View вирішує ЯКІ параметри
notes = selectors.get_user_notes(
request.user,
search=search or None,
tag=tag_id or None,
)
notebooks = selectors.get_user_notebooks(request.user)
tags = selectors.get_user_tags(request.user)
return render(request, 'hello_app/note_list.html', {
'notes': notes,
'notebooks': notebooks,
'tags': tags,
'search': search,
'active_tag': tag_id,
})
@login_required
def note_detail(request, pk):
"""Деталь нотатки. Selector piднімає Note.DoesNotExist якщо не власник."""
try:
note = selectors.get_note_detail(request.user, pk)
except Note.DoesNotExist:
from django.http import Http404
raise Http404("Нотатку не знайдено")
return render(request, 'hello_app/note_detail.html', {'note': note})
@login_required
def note_create(request):
"""
PRG: GET → порожня форма. POST → service.create_note() → redirect.
"""
if request.method == 'POST':
form = NoteForm(request.POST)
if form.is_valid():
note = services.create_note(
user=request.user,
title=form.cleaned_data['title'],
content=form.cleaned_data.get('content', ''),
priority=form.cleaned_data.get('priority', 1),
)
messages.success(request, f'Нотатку "{note.title}" створено!')
return redirect('hello_app:note_detail', pk=note.pk)
else:
form = NoteForm()
return render(request, 'hello_app/note_form.html', {
'form': form,
'action': 'Створити',
'title': 'Нова нотатка',
})
@login_required
def note_edit(request, pk):
"""Редагування. Service робить UPDATE тільки змінених полів."""
note = get_object_or_404(Note, pk=pk, user=request.user)
if request.method == 'POST':
form = NoteForm(request.POST, instance=note)
if form.is_valid():
services.update_note(
note,
title=form.cleaned_data['title'],
content=form.cleaned_data.get('content', ''),
priority=form.cleaned_data.get('priority', note.priority),
)
messages.success(request, f'Нотатку "{note.title}" оновлено!')
return redirect('hello_app:note_detail', pk=note.pk)
else:
form = NoteForm(instance=note)
return render(request, 'hello_app/note_form.html', {
'form': form,
'note': note,
'action': 'Зберегти',
'title': f'Редагувати: {note.title}',
})
@login_required
def note_delete(request, pk):
"""
Видалення через POST (не GET!).
GET → сторінка підтвердження. POST → service.delete_note() → redirect.
"""
note = get_object_or_404(Note, pk=pk, user=request.user)
if request.method == 'POST':
title = note.title
services.delete_note(note)
messages.warning(request, f'Нотатку "{title}" видалено.')
return redirect('hello_app:note_list')
return render(request, 'hello_app/note_confirm_delete.html', {'note': note})
Шаблони¶
hello_app/templates/hello_app/note_list.html:
{% extends 'base.html' %}
{% load static %}
{% block title %}Мої нотатки{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<!-- Sidebar: фільтри -->
<div class="col-md-3">
<div class="card mb-3">
<div class="card-header fw-bold">📓 Записники</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<a href="{% url 'hello_app:note_list' %}" class="text-decoration-none">
Всі нотатки
</a>
</li>
{% for nb in notebooks %}
<li class="list-group-item d-flex justify-content-between">
<a href="?notebook={{ nb.id }}" class="text-decoration-none">
{{ nb.title }}
</a>
<span class="badge bg-secondary">{{ nb.note_count }}</span>
</li>
{% endfor %}
</ul>
</div>
<div class="card">
<div class="card-header fw-bold">🏷️ Теги</div>
<div class="card-body">
{% for tag in tags %}
<a href="?tag={{ tag.id }}"
class="badge text-decoration-none me-1 mb-1"
style="background-color: {{ tag.color }};">
#{{ tag.name }}
<small>({{ tag.note_count }})</small>
</a>
{% endfor %}
</div>
</div>
</div>
<!-- Основний контент -->
<div class="col-md-9">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>📝 Нотатки</h2>
<a href="{% url 'hello_app:note_create' %}" class="btn btn-primary">
➕ Нова нотатка
</a>
</div>
<!-- Пошук -->
<form method="get" class="mb-3">
<div class="input-group">
<input type="text" name="q" value="{{ search }}"
class="form-control" placeholder="Пошук нотаток...">
<button type="submit" class="btn btn-outline-secondary">🔍</button>
</div>
</form>
<!-- Django Messages -->
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
<!-- Список нотаток -->
{% if notes %}
<div class="row row-cols-1 row-cols-md-2 g-3">
{% for note in notes %}
<div class="col">
<div class="card h-100 {% if note.is_pinned %}border-warning{% endif %}">
<div class="card-body">
<div class="d-flex justify-content-between">
<h5 class="card-title">
{% if note.is_pinned %}📌 {% endif %}
<a href="{% url 'hello_app:note_detail' note.pk %}"
class="text-decoration-none text-dark">
{{ note.title }}
</a>
</h5>
<!-- Пріоритет -->
<span class="badge
{% if note.priority == 4 %}bg-danger
{% elif note.priority == 3 %}bg-warning text-dark
{% elif note.priority == 2 %}bg-info text-dark
{% else %}bg-light text-dark{% endif %}">
{{ note.get_priority_display }}
</span>
</div>
<p class="card-text text-muted small">
{{ note.content|truncatechars:100 }}
</p>
<!-- Теги (завантажені через prefetch_related — без N+1!) -->
<div class="mb-2">
{% for tag in note.tags.all %}
<a href="?tag={{ tag.id }}"
class="badge text-decoration-none"
style="background-color: {{ tag.color }};">
#{{ tag.name }}
</a>
{% endfor %}
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<small class="text-muted">
📁 {{ note.notebook.title|default:"Без записника" }}
· {{ note.updated_at|date:"d.m.Y" }}
</small>
<div>
<a href="{% url 'hello_app:note_edit' note.pk %}"
class="btn btn-sm btn-outline-secondary">✏️</a>
<a href="{% url 'hello_app:note_delete' note.pk %}"
class="btn btn-sm btn-outline-danger">🗑️</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<div class="display-1">📝</div>
<p class="text-muted">
{% if search %}
Нотаток за запитом "{{ search }}" не знайдено.
{% else %}
У вас ще немає нотаток.
{% endif %}
</p>
<a href="{% url 'hello_app:note_create' %}" class="btn btn-primary">
Створити першу нотатку
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
hello_app/templates/hello_app/note_detail.html:
{% extends 'base.html' %}
{% block title %}{{ note.title }}{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'hello_app:note_list' %}">Нотатки</a>
</li>
{% if note.notebook %}
<li class="breadcrumb-item">{{ note.notebook.title }}</li>
{% endif %}
<li class="breadcrumb-item active">{{ note.title }}</li>
</ol>
</nav>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-start">
<div>
<h1 class="h3 mb-1">
{% if note.is_pinned %}📌 {% endif %}
{{ note.title }}
</h1>
<small class="text-muted">
Створено: {{ note.created_at|date:"d.m.Y H:i" }}
· Оновлено: {{ note.updated_at|date:"d.m.Y H:i" }}
</small>
</div>
<span class="badge
{% if note.priority == 4 %}bg-danger
{% elif note.priority == 3 %}bg-warning text-dark
{% elif note.priority == 2 %}bg-info text-dark
{% else %}bg-secondary{% endif %}">
{{ note.get_priority_display }}
</span>
</div>
<div class="card-body">
<!-- Теги -->
<div class="mb-3">
{% for tag in note.tags.all %}
<span class="badge" style="background-color: {{ tag.color }};">
#{{ tag.name }}
</span>
{% empty %}
<span class="text-muted small">Без тегів</span>
{% endfor %}
</div>
<!-- Вміст -->
<div class="note-content">
{{ note.content|linebreaks }}
</div>
</div>
<!-- Нагадування -->
{% if note.reminders.all %}
<div class="card-footer">
<h6>⏰ Майбутні нагадування:</h6>
{% for reminder in note.reminders.all %}
<div class="d-flex align-items-center mb-1">
<span class="badge bg-light text-dark me-2">
{{ reminder.remind_at|date:"d.m.Y H:i" }}
</span>
{% if reminder.message %}
<small>{{ reminder.message }}</small>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Кнопки дій -->
<div class="mt-3 d-flex gap-2">
<a href="{% url 'hello_app:note_edit' note.pk %}"
class="btn btn-outline-primary">✏️ Редагувати</a>
<a href="{% url 'hello_app:note_delete' note.pk %}"
class="btn btn-outline-danger">🗑️ Видалити</a>
<a href="{% url 'hello_app:note_list' %}"
class="btn btn-outline-secondary">← До списку</a>
</div>
</div>
</div>
</div>
{% endblock %}
hello_app/templates/hello_app/note_form.html:
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<div class="col-md-8 mx-auto">
<div class="card">
<div class="card-header">
<h2 class="h4 mb-0">{{ title }}</h2>
</div>
<div class="card-body">
<form method="post" novalidate>
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label class="form-label fw-semibold" for="{{ field.id_for_label }}">
{{ field.label }}
{% if field.field.required %}
<span class="text-danger">*</span>
{% endif %}
</label>
{% if field.field.widget.input_type == 'textarea' %}
<textarea
class="form-control {% if field.errors %}is-invalid{% endif %}"
id="{{ field.id_for_label }}"
name="{{ field.html_name }}"
rows="6">{{ field.value|default:'' }}</textarea>
{% else %}
<input
type="{{ field.field.widget.input_type }}"
class="form-control {% if field.errors %}is-invalid{% endif %}"
id="{{ field.id_for_label }}"
name="{{ field.html_name }}"
value="{{ field.value|default:'' }}">
{% endif %}
{% if field.errors %}
<div class="invalid-feedback">
{{ field.errors|join:', ' }}
</div>
{% endif %}
{% if field.help_text %}
<div class="form-text text-muted">{{ field.help_text }}</div>
{% endif %}
</div>
{% endfor %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
💾 {{ action }}
</button>
{% if note %}
<a href="{% url 'hello_app:note_detail' note.pk %}"
class="btn btn-outline-secondary">Скасувати</a>
{% else %}
<a href="{% url 'hello_app:note_list' %}"
class="btn btn-outline-secondary">Скасувати</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
hello_app/templates/hello_app/note_confirm_delete.html:
{% extends 'base.html' %}
{% block title %}Видалити нотатку{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<div class="col-md-6 mx-auto">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">🗑️ Підтвердження видалення</h4>
</div>
<div class="card-body">
<p class="mb-1">Ви впевнені що хочете видалити нотатку:</p>
<h5 class="text-danger">{{ note.title }}</h5>
{% if note.reminders.exists %}
<div class="alert alert-warning">
⚠️ Разом з нотаткою будуть видалені {{ note.reminders.count }} нагадування (CASCADE).
</div>
{% endif %}
<p class="text-muted small">Цю дію не можна відмінити.</p>
</div>
<div class="card-footer d-flex gap-2">
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">
Так, видалити
</button>
</form>
<a href="{% url 'hello_app:note_detail' note.pk %}"
class="btn btn-outline-secondary">Скасувати</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
Крок 8.5 — Детальний туторіал: кожна функція з поясненням¶
Тут ми розберемо КОЖНУ функцію з
selectors.py,services.py,views.pyіforms.pyпокроково — що відбувається всередині і навіщо.
selectors.py — функція за функцією¶
get_user_notes(user, *, archived, notebook, tag, search)¶
Крок за кроком:
# Крок 1: Базовий QuerySet (ЛІНИВИЙ — SQL ще не виконаний!)
qs = Note.objects.filter(user=user, is_archived=archived)
# SQL поки: WHERE note.user_id=? AND note.is_archived=?
# Але SQL НЕ виконується — Django чекає поки хтось ітерує QuerySet!
# Крок 2: select_related('notebook') — приєднуємо FK одним JOIN
qs = qs.select_related('notebook')
# SQL стане: LEFT JOIN hello_app_notebook nb ON note.notebook_id = nb.id
# Наслідок: note.notebook.title → НЕ запускає новий SELECT (вже в пам'яті!)
#
# БЕЗ select_related: for note in qs: print(note.notebook.title)
# → 1 SELECT + N SELECT = N+1 проблема!
# З select_related: 1 SELECT з JOIN — завжди!
# Крок 3: prefetch_related('tags') — завантажуємо M:N
qs = qs.prefetch_related('tags')
# ЗАПИТ 1: SELECT * FROM note WHERE ...
# ЗАПИТ 2: SELECT tag.* ... WHERE note_id IN (1, 2, 3, ...)
# Python: Django з'єднує самостійно
# Наслідок: note.tags.all() → вже в кеші, НЕ новий SELECT!
# Крок 4: Опційні фільтри — ЛАНЦЮГ (лінивий)
if notebook is not None:
qs = qs.filter(notebook=notebook)
# SQL додається: AND note.notebook_id = ?
if tag is not None:
qs = qs.filter(tags=tag)
# SQL: AND EXISTS (SELECT 1 FROM note_tags WHERE note_id=? AND tag_id=?)
if search:
qs = qs.filter(Q(title__icontains=search) | Q(content__icontains=search))
# SQL: AND (note.title ILIKE '%search%' OR note.content ILIKE '%search%')
# Крок 5: Сортування та повернення (ще ЛІНИВИЙ!)
return qs.order_by('-is_pinned', '-priority', '-updated_at')
# SQL: ORDER BY is_pinned DESC, priority DESC, updated_at DESC
# SQL виконається тільки коли View передає notes у шаблон і {% for note in notes %}!
get_note_detail(user, note_id)¶
Що робить: завантажує ONE нотатку з усіма зв'язками для сторінки деталі
Де викликається: views.note_detail()
Ключова відмінність від get_user_notes:
# get_user_notes → QuerySet (список, ще не виконано)
# get_note_detail → ОДИН об'єкт (вже виконано!)
note = Note.objects.select_related('notebook', 'user') # JOIN для FK
.prefetch_related(
'tags', # M:N: 2 запити
Prefetch( # ФІЛЬТРОВАНИЙ prefetch!
'reminders',
queryset=Reminder.objects.filter(
remind_at__gte=timezone.now() # тільки МАЙБУТНІ нагадування
).order_by('remind_at'),
to_attr='upcoming_reminders' # зберігаємо у ОКРЕМИЙ атрибут!
)
).get(id=note_id, user=user) # .get() → ВИКОНУЄ SQL + перевіряє права
# Чому Prefetch з to_attr?
# БЕЗ to_attr: note.reminders.all() → NEW QuerySet (ще один SELECT!)
# note.reminders.filter(...) → NEW QuerySet (інший SELECT!)
# З to_attr: note.upcoming_reminders → list[Reminder] (вже в пам'яті!)
# У шаблоні: {% for r in note.upcoming_reminders %} ← НЕ SELECT!
get_user_notebooks(user) та get_user_tags(user)¶
Що роблять: записники/теги з КІЛЬКІСТЮ нотаток в одному SQL
Де викликаються: views.note_list() → sidebar
Чому annotate а не окремий count():
# ❌ НЕПРАВИЛЬНО: N+1!
notebooks = Notebook.objects.filter(user=user)
for nb in notebooks:
count = nb.notes.filter(is_archived=False).count() # окремий SELECT!
# 10 записників → 11 SQL запитів!
# ✅ ПРАВИЛЬНО: один GROUP BY
notebooks = Notebook.objects.filter(user=user).annotate(
note_count=Count('notes', filter=Q(notes__is_archived=False))
)
# SQL: SELECT nb.*, COUNT(note.id) FILTER (WHERE note.is_archived=FALSE) AS note_count
# FROM notebook nb LEFT JOIN note ON note.notebook_id=nb.id
# WHERE nb.user_id=? GROUP BY nb.id
# 1 SQL! nb.note_count → число, вже в пам'яті!
get_user_todo_lists(user) — подвійна анотація¶
TodoList.objects.filter(user=user).annotate(
total_items=Count('items'), # всього пунктів
done_items=Count('items', filter=Q(items__is_done=True)) # виконаних
)
# SQL: SELECT todolist.*,
# COUNT(item.id) AS total_items,
# COUNT(item.id) FILTER (WHERE item.is_done=TRUE) AS done_items
# FROM todolist LEFT JOIN todoitem item ON item.todo_list_id = todolist.id
# WHERE todolist.user_id = ? GROUP BY todolist.id
#
# У шаблоні:
# todo.total_items → 5 (без жодного SELECT!)
# todo.done_items → 3 (без жодного SELECT!)
# Прогрес: 3/5 = 60%
get_pending_reminders() — для Celery¶
# Ця функція НЕ для View — для background task!
Reminder.objects.filter(
is_sent=False, # ще не відправлені
remind_at__lte=timezone.now() # час прийшов (remind_at <= зараз)
).select_related('note__user') # traversal ЧЕРЕЗ ДВА FK
# SQL JOIN: reminder → note → user
# reminder.note.user.email → без жодного додаткового SELECT!
# Використання в Celery:
# @shared_task
# def send_reminders():
# for r in selectors.get_pending_reminders():
# send_email(to=r.note.user.email, ...) ← без N+1!
# services.mark_reminder_sent(r.id)
services.py — функція за функцією¶
create_note(*, user, title, content, notebook, priority, tag_ids)¶
Що робить: атомарно створює нотатку + прив'язує теги
Де викликається: views.note_create() після form.is_valid()
Крок за кроком:
# Чому зірочка * перед аргументами?
# def create_note(*, user, title, ...)
# → всі аргументи KEYWORD-ONLY (немає позиційних після *)
# → виклик: services.create_note(user=user, title="Test") ✓
# → виклик: services.create_note(user, "Test") ✗ TypeError!
# Навіщо? Читабельніше, менше помилок при рефакторингу.
with transaction.atomic(): # SQL: BEGIN;
# Крок 1: INSERT нотатку
note = Note.objects.create(user=user, title=title, ...)
# SQL: INSERT INTO note (...) VALUES (...) RETURNING id, created_at, ...
if tag_ids:
# Крок 2: Перевіряємо теги (БЕЗПЕКА!)
valid_tags = Tag.objects.filter(id__in=tag_ids, user=user)
# SQL: SELECT * FROM tag WHERE id IN (1,2,3) AND user_id=?
# Без user=user: Alice могла б додати тег Bob'а до своєї нотатки!
# Крок 3: Встановлюємо M:N зв'язок
note.tags.set(valid_tags)
# SQL: INSERT INTO hello_app_note_tags (note_id, tag_id) VALUES (?,?), ...
# Крок 4: COMMIT (або ROLLBACK якщо будь-яка операція впала!)
# Якщо tags.set() кине Exception → автоматичний ROLLBACK для Note теж!
return note # після COMMIT: note.id доступний
update_note(note, *, title, content, priority, ...) — часткове оновлення¶
# Патерн "часткове оновлення":
# - Якщо аргумент=None → поле НЕ оновлюється
# - Якщо аргумент передано → оновлюємо
changed_fields = []
if title is not None:
note.title = title
changed_fields.append('title')
# ... для кожного поля ...
if changed_fields:
note.save(update_fields=changed_fields)
# update_fields=['title'] → SQL: UPDATE note SET title=? WHERE id=?
# Без update_fields: → SQL: UPDATE note SET title=?, content=?,
# priority=?, is_pinned=?, ... (ВСІ поля!)
# З update_fields: тільки змінені! Безпечніше для конкурентних записів.
# M:N: окрема операція (не в update_fields!)
if tag_ids is not None: # None = "не змінювати", [] = "видалити всі"
note.tags.set(Tag.objects.filter(id__in=tag_ids, user=note.user))
toggle_todo_item(item_id, *, user) — race condition prevention¶
# ЧОМУ select_for_update()?
# Проблема без блокування:
# Thread A: SELECT item WHERE id=5 → is_done=False
# Thread B: SELECT item WHERE id=5 → is_done=False (ТЕ Ж ЗНАЧЕННЯ!)
# Thread A: UPDATE item SET is_done=True
# Thread B: UPDATE item SET is_done=True ← обидва встановлюють True!
# Помилка: другий клік мав би зняти відмітку!
with transaction.atomic(): # select_for_update ВИМАГАЄ atomic!
item = TodoItem.objects.select_for_update().get(
id=item_id,
todo_list__user=user # БЕЗПЕКА: перевіряємо право доступу через FK!
)
# SQL: SELECT ... FROM todoitem
# JOIN todolist ON todoitem.todo_list_id = todolist.id
# WHERE todoitem.id=? AND todolist.user_id=?
# FOR UPDATE ← блокує рядок!
item.is_done = not item.is_done
item.save(update_fields=['is_done'])
# COMMIT → знімає блокування рядка
create_or_get_tag(*, user, name, color) — idempotent операція¶
tag, created = Tag.objects.get_or_create(
user=user,
name=name.lower().strip(), # нормалізація: "Python" → "python"
defaults={'color': color} # color встановлюється ТІЛЬКИ при CREATE
)
# get_or_create:
# 1. SELECT WHERE user=? AND name=?
# 2. Якщо знайдено → return (existing_tag, False)
# 3. Якщо не знайдено → INSERT → return (new_tag, True)
#
# Атомарно! UNIQUE constraint (user, name) захищає від race condition.
# Два запити одночасно: один INSERT виграє, другий отримує SELECT.
return tag, created
# views.py: tag, created = services.create_or_get_tag(user=user, name='python')
complete_todo_list(todo_list) — масове оновлення¶
with transaction.atomic():
# Крок 1: позначаємо список
todo_list.is_completed = True
todo_list.save(update_fields=['is_completed'])
# Крок 2: масово позначаємо всі пункти
todo_list.items.filter(is_done=False).update(is_done=True)
# SQL: UPDATE todoitem SET is_done=TRUE
# WHERE todo_list_id=? AND is_done=FALSE
#
# НЕПРАВИЛЬНО:
# for item in todo_list.items.all():
# item.is_done = True
# item.save() # N запитів замість 1!
#
# ПРАВИЛЬНО: .update() → 1 SQL для всіх рядків!
forms.py — функція за функцією¶
NoteForm.__init__(self, *args, user=None, **kwargs)¶
def __init__(self, *args, user=None, **kwargs):
# Крок 1: ініціалізуємо батьківський ModelForm
super().__init__(*args, **kwargs)
# Після super().__init__:
# self.fields = {
# 'title': CharField(max_length=200),
# 'notebook': ModelChoiceField(queryset=Notebook.objects.all()), ← ALL!
# 'tags': ModelMultipleChoiceField(queryset=Tag.objects.all()), ← ALL!
# ...
# }
# Крок 2: фільтруємо queryset по user (БЕЗПЕКА!)
if user is not None:
self.fields['notebook'].queryset = Notebook.objects.filter(user=user)
# Тепер dropdown показує ТІЛЬКИ записники цього юзера!
self.fields['tags'].queryset = Tag.objects.filter(user=user)
# Тепер чекбокси показують ТІЛЬКИ теги цього юзера!
else:
self.fields['notebook'].queryset = Notebook.objects.none()
self.fields['tags'].queryset = Tag.objects.none()
# Крок 3: кастомний label для порожнього варіанту
self.fields['notebook'].empty_label = '── Без записника ──'
self.fields['notebook'].required = False
# Виклики:
# GET: form = NoteForm(user=request.user) → порожня форма
# GET: form = NoteForm(instance=note, user=request.user) → форма для редагування
# POST: form = NoteForm(request.POST, user=request.user) → валідація
TagForm.clean_name(self) — кастомна очистка поля¶
def clean_name(self):
# Validation Pipeline:
# 1. to_python(" Python ") → str " Python "
# 2. validate(" Python ") → OK (required, max_length)
# 3. run_validators → (жодних кастомних)
# 4. clean_name(self) ← МИ ТУТ
# self.cleaned_data['name'] = " Python " (рядок що пройшов validate)
name = self.cleaned_data['name']
normalized = name.lower().strip() # " Python " → "python"
if not normalized:
raise forms.ValidationError("Назва тегу не може бути порожньою.")
return normalized # ← ОБОВ'ЯЗКОВО повертати!
# cleaned_data['name'] = "python" (нормалізовано!)
views.py — функція за функцією¶
note_list(request) — GET запит з фільтрами¶
# Крок 1: Читаємо фільтри з URL
search = request.GET.get('q', '').strip() # '' якщо відсутній
tag_id = request.GET.get('tag') # '7' або None
# Крок 2: Конвертуємо id → об'єкти (з перевіркою прав!)
tag = None
if tag_id:
try:
tag = Tag.objects.get(id=int(tag_id), user=request.user)
# ↑ ↑
# конвертація рядка→int тільки свій тег!
except (Tag.DoesNotExist, ValueError):
pass # некоректний id → просто ігноруємо
# Крок 3: Отримуємо дані через selector
notes = selectors.get_user_notes(request.user, search=search or None, tag=tag)
# '' → None: щоб selector знав що search відсутній (не порожній рядок)
# Крок 4: Render (тут QuerySet виконається!)
return render(request, 'hello_app/note_list.html', {'notes': notes, ...})
# Під час рендеру: {% for note in notes %} → SQL SELECT виконується!
note_create(request) — PRG патерн¶
if request.method == 'POST':
# 1. Прив'язуємо POST дані до форми (форма стає "bound")
form = NoteForm(request.POST, user=request.user)
# 2. Validation Pipeline:
# to_python → validate → run_validators → clean_<field> → clean
if form.is_valid():
# 3. Витягуємо tag_ids з M:N поля
tags = form.cleaned_data.get('tags') # QuerySet[Tag] або None
tag_ids = [t.id for t in tags] if tags else None # [1, 3] або None
# 4. Бізнес-логіка → Service (не тут у View!)
note = services.create_note(
user=request.user,
title=form.cleaned_data['title'], # типізований str (validated!)
tag_ids=tag_ids,
...
)
# 5. PRG: messages + redirect (не render!)
messages.success(request, f'Нотатку "{note.title}" створено!')
return redirect('hello_app:note_detail', pk=note.pk)
# HTTP 302 → GET /notes/42/ → безпечно, F5 не дублює!
# Якщо is_valid=False → падаємо до render нижче
else: # GET
form = NoteForm(user=request.user) # порожня форма
# Рендер для GET і для POST з помилками:
return render(request, 'hello_app/note_form.html', {'form': form, ...})
note_delete(request, pk) — чому тільки POST¶
# Чому GET не може видаляти?
# Браузер, боти, антивіруси АВТОМАТИЧНО роблять GET запити
# (prefetch links, перевірка доступності тощо)
# GET /notes/42/delete/ якби видаляв → нотатки зникали б "самі"!
note = get_object_or_404(Note, pk=pk, user=request.user)
# SQL: SELECT WHERE id=42 AND user_id=request.user.id
# 404 якщо не знайдено → Alice не може видалити нотатки Bob'а!
if request.method == 'POST': # форма в шаблоні підтвердження
title = note.title # зберігаємо ПЕРЕД видаленням
services.delete_note(note)
# CASCADE: видалення note → автоматично видаляє Reminder
messages.warning(request, f'Нотатку "{title}" видалено.')
return redirect('hello_app:note_list')
# GET → показуємо сторінку з попередженням і формою підтвердження
return render(request, 'hello_app/note_confirm_delete.html', {'note': note})
Крок 9 — QuerySets¶
select_related — вирішення N+1 для FK¶
# ❌ N+1: 100 нотаток + 100 запитів для user + 100 для category = 201 запит
notes = Note.objects.filter(is_archived=False)[:100]
for note in notes:
print(note.user.username, note.notebook.title) # нові SQL для кожного!
# ✅ select_related: 1 запит з JOIN
notes = Note.objects.select_related(
'user', 'notebook'
).filter(is_archived=False)[:100]
for note in notes:
print(note.user.username, note.notebook.title) # вже в пам'яті
prefetch_related — для M:N та reverse FK¶
# ✅ 2 запити: один для notes, один для tags
notes = Note.objects.prefetch_related('tags').all()[:50]
for note in notes:
print([t.name for t in note.tags.all()]) # вже в пам'яті!
# ✅ Комбінація (найефективніше для note_list)
notes = (
Note.objects
.select_related('user', 'notebook') # FK → SQL JOIN (1 запит)
.prefetch_related('tags', 'reminders') # M:N + reverse FK (2 запити)
.filter(is_archived=False)
.order_by('-is_pinned', '-priority', '-updated_at')
)
F() — atomic операції¶
from django.db.models import F
# ✅ Атомарний інкремент (race-condition safe!)
# UPDATE hello_app_note SET views_count = views_count + 1 WHERE id = 42
Note.objects.filter(pk=42).update(views_count=F('views_count') + 1)
# ✅ Порівняння двох полів між собою
Note.objects.filter(views_count__gt=F('likes_count'))
# WHERE views_count > likes_count (порівняння СТОВПЦІВ, не значень)
# ❌ Небезпечно при конкурентних запитах:
note = Note.objects.get(pk=42)
note.views_count += 1 # читає в Python
note.save() # записує назад → race condition!
transaction.atomic()¶
from django.db import transaction
# Атомарна операція: або обидва INSERT, або жоден
with transaction.atomic():
note = Note.objects.create(
title='Нова нотатка', user=user, content='...'
)
Reminder.objects.create(
note=note,
remind_at=timezone.now() + timedelta(days=1),
message='Перегляньте завтра'
)
# Якщо тут exception → обидва INSERT відкочуються (ROLLBACK)
select_for_update() — запобігання race conditions¶
with transaction.atomic():
# SELECT ... FOR UPDATE → блокує рядок до кінця транзакції
note = Note.objects.select_for_update().get(pk=note_id)
if not note.is_archived:
note.is_archived = True
note.save(update_fields=['is_archived'])
# COMMIT → lock знімається
# nowait=True: кидає DatabaseError замість чекання якщо рядок вже заблокований
try:
note = Note.objects.select_for_update(nowait=True).get(pk=note_id)
except DatabaseError:
return JsonResponse({'error': 'Ресурс зайнятий, спробуйте пізніше'}, status=409)
Структура файлів¶
notes_project/
├── notes_project/ ← Django project config
│ ├── settings.py ← DATABASES, INSTALLED_APPS, AUTH
│ ├── urls.py ← root URL router
│ └── wsgi.py / asgi.py ← production entry point
│
├── hello_app/ ← Django додаток
│ ├── models.py ← 8 моделей (UserProfile, Tag, Notebook, Note,
│ │ Reminder, TodoList, TodoItem, ShoppingList, ShopItem)
│ ├── selectors.py ← ВСІ SELECT запити тут (читання)
│ ├── services.py ← ВСЯ бізнес-логіка тут (запис/зміна)
│ ├── views.py ← ТІЛЬКИ HTTP: парсити запит → selector/service → render
│ ├── forms.py ← Django Forms для валідації вводу
│ ├── admin.py ← реєстрація в адмін-панелі (з select_related)
│ ├── urls.py ← app URL маршрути
│ └── migrations/
│ ├── 0001_initial.py ← стартова Note модель
│ └── 0002_extended_models.py ← всі розширені моделі
│
├── hello_app/templates/hello_app/
│ ├── note_list.html ← список нотаток + sidebar фільтри
│ ├── note_detail.html ← деталь нотатки + нагадування
│ ├── note_form.html ← форма створення/редагування
│ └── note_confirm_delete.html ← сторінка підтвердження видалення
│
├── templates/
│ └── base.html ← базовий шаблон (Bootstrap navbar, messages)
│
├── manage.py
├── requirements.txt ← Django, psycopg2-binary, python-decouple
├── docker-compose.yml ← PostgreSQL у Docker
└── .env ← секрети (ніколи не комітити!)
Де живе кожна відповідальність:¶
| Компонент | Відповідальність | НЕ робить |
|---|---|---|
models.py |
Описує структуру даних і зв'язки | Бізнес-логіка, HTTP |
selectors.py |
SELECT запити (читання) | INSERT/UPDATE/DELETE |
services.py |
Бізнес-логіка, CREATE/UPDATE/DELETE | HTTP, render |
views.py |
HTTP: парсить запит, викликає selector/service, рендерить | QuerySets, логіка |
forms.py |
Валідація вводу з HTTP | БД запити |
templates/ |
HTML розмітка | Бізнес-логіка |
Крок 10 — PostgreSQL¶
10.1 Запуск PostgreSQL через Docker¶
# Запуск через docker-compose:
docker-compose up -d
# Або прямо через docker run:
docker run -d \
--name notes_postgres \
-e POSTGRES_DB=notes_db \
-e POSTGRES_USER=notes_user \
-e POSTGRES_PASSWORD=notes_pass \
-p 5432:5432 \
postgres:16-alpine
# docker-compose.yml
version: '3.9'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: notes_db
POSTGRES_USER: notes_user
POSTGRES_PASSWORD: notes_pass
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
10.2 Налаштування Django¶
# settings.py — після переходу на PostgreSQL
from decouple import config
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME', default='notes_db'),
'USER': config('DB_USER', default='notes_user'),
'PASSWORD': config('DB_PASSWORD', default='notes_pass'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432'),
'CONN_MAX_AGE': 60, # тримати з'єднання між запитами
}
}
# .env (ніколи не комітити!)
DB_NAME=notes_db
DB_USER=notes_user
DB_PASSWORD=notes_pass
DB_HOST=localhost
DB_PORT=5432
10.3 Перенесення даних з SQLite¶
# Крок 1: Дамп з SQLite (поки ще підключена)
python manage.py dumpdata \
--natural-foreign --natural-primary \
--exclude=contenttypes --exclude=auth.permission \
--indent=2 > data_backup.json
# Крок 2: Перемикаємо settings.py на PostgreSQL
# Крок 3: Міграції в нову БД
python manage.py migrate
# Крок 4: Завантаження даних
python manage.py loaddata data_backup.json
# Крок 5: Перевірка
python manage.py shell -c "from hello_app.models import Note; print(f'Notes: {Note.objects.count()}')"
10.4 PostgreSQL-специфічні поля (після переходу)¶
# Тільки PostgreSQL — ArrayField, JSONField з повними можливостями
from django.contrib.postgres.fields import ArrayField
class Note(models.Model):
# Масив тегів у стовпці (alternative до M:N для простих сценаріїв)
quick_labels = ArrayField(
models.CharField(max_length=30),
blank=True, default=list
)
# metadata як JSON (не потребує окремої таблиці)
metadata = models.JSONField(default=dict, blank=True)
# Фільтрація по JSON/Array:
Note.objects.filter(quick_labels__contains=['python'])
Note.objects.filter(metadata__word_count__gte=100)
Note.objects.filter(metadata__language='uk')