Group Sharing — Спільний доступ через Django Groups¶
Ідея: Аліса хоче ділитись нотатками з командою. Вона створює групу "Команда розробки" і додає Боба. Нотатки та списки покупок позначені цією групою бачать обидва.
Django вбудована модель Group¶
Django вже має готову модель Group у django.contrib.auth:
from django.contrib.auth.models import Group
# Кожна Group має:
# id — PK
# name — назва ('Сімя', 'Команда', 'Клас')
# user_set — M2N зв'язок з User (через auth_user_groups junction table)
# permissions — M2N зв'язок з Permission (для role-based access — не наш випадок)
# У notes_app ми використовуємо Group для ШЕРИНГУ ДАНИХ між юзерами,
# а не для Django Permission System (is_staff, is_superuser, permissions).
ER-Діаграма: User, Group, Note, ShoppingList¶
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ User │ │ auth_user_groups │ │ Group │
│──────────────│ │──────────────────────│ │──────────────│
│ id (PK) │◄───────►│ user_id (FK → User) │◄───────►│ id (PK) │
│ username │ M:N │ group_id (FK → Group)│ M:N │ name │
│ password │ └──────────────────────┘ └──────┬───────┘
│ email │ │
└──────┬───────┘ │ 1:N (FK SET_NULL)
│ │
│ 1:N (FK CASCADE) │
│ │
▼ ▼
┌────────────────────────────────────────────────────────────────────────────┐
│ Note │
│────────────────────────────────────────────────────────────────────────────│
│ id (PK) │
│ title │
│ content │
│ user_id (FK → User, CASCADE) ← обов'язковий: хто власник │
│ group_id (FK → Group, SET_NULL) ← необов'язковий: для шерингу │
│ notebook_id (FK → Notebook, SET_NULL) │
│ priority, is_pinned, is_archived ... │
└────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────┐
│ ShoppingList │
│────────────────────────────────────────────────────────────────────────────│
│ user_id (FK → User, CASCADE) ← власник │
│ group_id (FK → Group, SET_NULL) ← для шерингу │
└────────────────────────────────────────────────────────────────────────────┘
Якщо group_id = NULL → особиста нотатка (тільки user_id бачить)
Якщо group_id = 5 → групова нотатка (всі члени групи 5 бачать)
Group FK у моделях: Note і ShoppingList¶
Місце: notes_app/models.py
from django.contrib.auth.models import User, Group
class Note(models.Model):
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name='notes'
)
# ── Group FK: для спільного доступу ──────────────────────────────────────
group = models.ForeignKey(
Group,
on_delete=models.SET_NULL, # ← SET_NULL, не CASCADE!
null=True, # ← NULL = особиста нотатка (за замовчуванням)
blank=True, # ← у формі поле необов'язкове
related_name='notes',
)
# ── решта полів ───────────────────────────────────────────────────────────
notebook = models.ForeignKey(
'Notebook', on_delete=models.SET_NULL, null=True, blank=True,
related_name='notes'
)
title = models.CharField(max_length=200)
content = models.TextField(blank=True)
priority = models.PositiveSmallIntegerField(default=1)
is_pinned = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
tags = models.ManyToManyField('Tag', blank=True)
class ShoppingList(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
# ShoppingList теж має group FK для шерингу:
group = models.ForeignKey(
Group,
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='shopping_lists',
)
title = models.CharField(max_length=200)
store_name = models.CharField(max_length=100, blank=True)
Чому SET_NULL, а не CASCADE?¶
CASCADE: видалення групи → видаляються ВСІ нотатки групи
Аліса видаляє "Команда розробки" → Боб втрачає спільні нотатки!
Несподівано і незворотньо.
SET_NULL: видалення групи → нотатки залишаються, але стають особистими (group=NULL)
Аліса видаляє групу → нотатки переходять до особистих просторів власників
Безпечна поведінка: дані не губляться, тільки шерінг зупиняється.
Django виконує SET_NULL автоматично в CASCADE delete:
DELETE FROM auth_group WHERE id=5
→ UPDATE note SET group_id=NULL WHERE group_id=5 ← автоматично!
→ UPDATE shoppinglist SET group_id=NULL WHERE group_id=5 ← теж!
Q-filter: "мої або групові"¶
# notes_app/selectors.py
from django.db.models import Q
def get_user_notes(user, *, archived=False, notebook=None, tag=None, search=None):
"""Повертає нотатки юзера + нотатки груп де він є членом."""
user_groups = user.groups.all() # всі групи юзера (1 SQL)
qs = Note.objects.filter(
Q(user=user) | # власні нотатки
Q(group__in=user_groups), # нотатки груп де user є членом
is_archived=archived,
).select_related('notebook', 'group').prefetch_related('tags')
# ...
return qs.order_by('-is_pinned', '-priority', '-updated_at')
Аліса є в групах: [Команда(id=1), Сімя(id=3)]
SQL (спрощено):
SELECT * FROM note
WHERE (user_id = 42) -- особисті нотатки Аліси
OR (group_id IN (1, 3)) -- нотатки її груп
AND is_archived = FALSE
ORDER BY is_pinned DESC, priority DESC, updated_at DESC
Порівняння підходів до шерингу¶
M2M shared_with (TodoList) |
Group FK (Note, ShoppingList) | |
|---|---|---|
| Де | TodoList.shared_with = ManyToManyField(User) |
Note.group = ForeignKey(Group) |
| Кому шерити | Конкретним юзерам (список осіб) | Всім членам групи |
| Управління | Через форму з чекбоксами | Через /groups/<pk>/ |
| Коли підходить | "Поділитись з конкретним Бобом" | "Поділитись з усією командою" |
| Видалення | ManyToMany запис видаляється |
SET_NULL → нотатка стає особистою |
Крок 8 — Selectors update для Group¶
Місце: notes_app/selectors.py
from django.db.models import Q, Count
from django.contrib.auth.models import Group
from .models import Note, Notebook, Tag
def get_user_notes(user, *, archived=False, notebook=None, tag=None, search=None):
"""
Повертає нотатки юзера + нотатки груп де він є членом.
Q(user=user) | Q(group__in=user_groups) — ключовий Q-фільтр.
select_related/prefetch_related: мінімум SQL запитів (без N+1).
"""
user_groups = user.groups.all()
qs = Note.objects.filter(
Q(user=user) | Q(group__in=user_groups), # власні або групові
is_archived=archived,
).select_related(
'notebook', # JOIN → без +1 SQL при відображенні кольору записника
'group', # JOIN → без +1 SQL при відображенні назви групи
).prefetch_related(
'tags', # IN query → без +1 SQL при відображенні тегів
)
if notebook is not None:
qs = qs.filter(notebook=notebook)
if tag is not None:
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_user_groups(user):
"""Групи юзера з кількістю учасників (annotate)."""
return user.groups.annotate(
member_count=Count('user')
# annotate: додає поле 'member_count' до кожного Group об'єкта
# SQL: SELECT g.*, COUNT(ug.user_id) as member_count
# FROM auth_group g
# JOIN auth_user_groups ug ON ug.group_id = g.id
# WHERE g.id IN (Alice's group ids)
# GROUP BY g.id
).order_by('name')
def get_group_with_members(group_id, user):
"""
Повертає групу з учасниками ТІЛЬКИ якщо юзер є її членом.
Якщо не член → None (не Http404 — вирішує view).
"""
try:
group = Group.objects.prefetch_related('user_set').get(pk=group_id)
if not group.user_set.filter(pk=user.pk).exists():
return None # не член → view поверне 404
return group
except Group.DoesNotExist:
return None
Пояснення select_related vs prefetch_related:
# select_related: JOIN → один SQL запит
# Використовується для ForeignKey і OneToOne (N-to-1)
.select_related('notebook')
# SQL: SELECT note.*, nb.* FROM note LEFT JOIN notebook nb ON note.notebook_id = nb.id
# prefetch_related: окремий IN запит → два SQL запити
# Використовується для ManyToMany (M-to-N)
.prefetch_related('tags')
# SQL 1: SELECT note.* FROM note WHERE ...
# SQL 2: SELECT tag.* FROM tag INNER JOIN note_tags ON ... WHERE note_id IN (1,2,3,...)
# Без select_related/prefetch_related → N+1 проблема:
# 10 нотаток → 10 запитів для notebook + 10 для tags = 21 SQL запит!
# З оптимізацією → 3 SQL запити (notes + notebooks + tags)
Крок 9 — Services update для Group¶
Місце: notes_app/services.py
from django.contrib.auth.models import Group, User
from django.db import transaction
def create_group(*, name, creator):
"""
Створює групу і автоматично додає creator як першого учасника.
Без транзакції: якщо add() провалиться → Group існує без учасників.
З atomic(): обидві операції або обидві rollback.
"""
with transaction.atomic():
group = Group.objects.create(name=name)
group.user_set.add(creator) # M:N INSERT у auth_user_groups
return group
def add_user_to_group(group, username):
"""
Додає юзера до групи за username.
Повертає (True, '') або (False, 'повідомлення про помилку').
Чому не raise Exception?
View очікує tuple (ok, msg) щоб відобразити помилку у формі без 500.
"""
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return False, f'Користувача «{username}» не знайдено.'
if group.user_set.filter(pk=user.pk).exists():
return False, f'«{username}» вже є членом цієї групи.'
group.user_set.add(user)
return True, ''
def remove_user_from_group(group, user):
"""Видаляє юзера з групи. M:N DELETE з auth_user_groups."""
group.user_set.remove(user)
def delete_group(group):
"""
Видаляє групу.
Django автоматично SET_NULL для Note.group і ShoppingList.group.
Жодних нотаток не втрачається.
"""
group.delete()
Крок 10 — Group Views¶
Місце: notes_app/views.py
from django.contrib.auth.models import Group, User
from django.core.exceptions import PermissionDenied
from django.http import Http404
from . import selectors, services
@login_required
def group_list(request):
"""Список груп до яких належить поточний юзер."""
groups = selectors.get_user_groups(request.user)
return render(request, 'notes_app/group_list.html', {'groups': groups})
@login_required
def group_create(request):
"""
Створення нової групи. Creator автоматично стає першим учасником.
Валідація — через форму GroupCreateForm, а не вручну через request.POST.get().
Переваги форм над raw POST:
• clean_name() перевіряє унікальність назви автоматично
• Всі помилки доступні через form.errors → рендеримо в шаблоні
• Узгоджено з іншими view — один підхід у всьому проєкті
• DRY: логіка валідації в одному місці (forms.py), не в кожному view
"""
if request.method == 'POST':
form = GroupCreateForm(request.POST)
if form.is_valid():
group = services.create_group(
name=form.cleaned_data['name'],
creator=request.user,
)
messages.success(request, f'✅ Групу «{group.name}» створено! Ви перший учасник.')
return redirect('notes_app:group_detail', pk=group.pk)
else:
form = GroupCreateForm()
return render(request, 'notes_app/group_form.html', {
'form': form, 'title': 'Нова група', 'action': 'Створити',
})
@login_required
def group_detail(request, pk):
"""
Перегляд групи: учасники + дії (додати/видалити/покинути).
Один view — три дії через один POST:
action='add' → додати учасника за username
action='remove' → видалити конкретного учасника (по user_pk)
action='leave' → поточний юзер покидає групу
"""
group = selectors.get_group_with_members(pk, request.user)
if group is None:
raise Http404('Групу не знайдено або у вас немає доступу.')
error = None
if request.method == 'POST':
action = request.POST.get('action')
if action == 'add':
username = request.POST.get('username', '').strip()
ok, msg = services.add_user_to_group(group, username)
if ok:
messages.success(request, f'«{username}» додано до групи.')
return redirect('notes_app:group_detail', pk=pk)
else:
error = msg
elif action == 'remove':
remove_pk = request.POST.get('user_pk')
try:
target = User.objects.get(pk=remove_pk)
if target == request.user:
messages.error(request, 'Щоб покинути групу — використай кнопку "Покинути".')
else:
services.remove_user_from_group(group, target)
messages.success(request, f'«{target.username}» видалено з групи.')
except User.DoesNotExist:
pass
return redirect('notes_app:group_detail', pk=pk)
elif action == 'leave':
group_name = group.name
services.remove_user_from_group(group, request.user)
messages.info(request, f'Ви покинули групу «{group_name}».')
return redirect('notes_app:group_list')
return render(request, 'notes_app/group_detail.html', {
'group': group,
'members': group.user_set.all(),
'error': error,
})
@login_required
def group_delete(request, pk):
"""
Видалення групи.
Тільки якщо request.user є членом → 403 (ми знаємо що група існує).
"""
group = get_object_or_404(Group, pk=pk)
if not group.user_set.filter(pk=request.user.pk).exists():
raise PermissionDenied # 403: знаємо що існує, але не маємо доступу
if request.method == 'POST':
name = group.name
services.delete_group(group)
messages.warning(
request,
f'Групу «{name}» видалено. Спільні нотатки і списки стали особистими.'
)
return redirect('notes_app:group_list')
return render(request, 'notes_app/group_confirm_delete.html', {'group': group})
Ключовий патерн — один view, три дії:
POST /groups/5/ {action: 'add', username: 'bob'} → додати Боба
POST /groups/5/ {action: 'remove', user_pk: 99} → видалити user.pk=99
POST /groups/5/ {action: 'leave'} → поточний юзер іде
Навіщо один view а не три URL?
- Вся логіка управління групою в одному місці
- Спільний middleware check (membership)
- Простіше для тестування (один тест класу TestCase)
- Менше URL patterns
Крок 11 — Group Templates¶
Місце: notes_app/templates/notes_app/
group_list.html — картки груп¶
{% extends 'layouts/dashboard.html' %}
{% block topbar_title %}Мої групи{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-semibold mb-0">
<i class="bi bi-people me-2 text-primary"></i>Мої групи
</h5>
<a href="{% url 'notes_app:group_create' %}" class="btn btn-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>Нова група
</a>
</div>
{% if groups %}
<div class="row row-cols-1 row-cols-md-3 g-3">
{% for group in groups %}
<div class="col">
<div class="card h-100 shadow-sm border-0">
<div class="card-body">
<h6 class="fw-semibold mb-1">{{ group.name }}</h6>
<p class="text-muted small mb-0">
<i class="bi bi-person me-1"></i>
{{ group.member_count }} учасник{{ group.member_count|pluralize:",и,ів" }}
{# member_count = annotate з get_user_groups selector #}
</p>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<a href="{% url 'notes_app:group_detail' group.pk %}"
class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-right me-1"></i>Відкрити
</a>
<a href="{% url 'notes_app:group_chat' group.pk %}"
class="btn btn-outline-success btn-sm ms-1">
<i class="bi bi-chat me-1"></i>Чат
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-people display-4 mb-3"></i>
<h6>Груп ще немає</h6>
<p class="small">Створи групу і додай учасників для спільного доступу до нотаток.</p>
<a href="{% url 'notes_app:group_create' %}" class="btn btn-primary btn-sm">
Створити першу групу
</a>
</div>
{% endif %}
{% endblock %}
group_detail.html — учасники і дії¶
{% extends 'layouts/dashboard.html' %}
{% block topbar_title %}Група: {{ group.name }}{% endblock %}
{% block content %}
<div class="row g-4">
<!-- ── Список учасників ── -->
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-people me-2"></i>Учасники ({{ members.count }})
</h6>
</div>
<ul class="list-group list-group-flush">
{% for member in members %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<i class="bi bi-person-circle me-2 text-muted"></i>
{{ member.username }}
{% if member == request.user %}
<span class="badge bg-success rounded-pill ms-1">Ти</span>
{% endif %}
</span>
<!-- Видалити учасника — action=remove -->
{% if member != request.user %}
<form method="post" class="d-inline">
{% csrf_token %}
<input type="hidden" name="action" value="remove">
<input type="hidden" name="user_pk" value="{{ member.pk }}">
<button type="submit"
class="btn btn-outline-danger btn-sm"
onclick="return confirm('Видалити {{ member.username }} з групи?')">
<i class="bi bi-x-lg"></i>
</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- ── Панель дій ── -->
<div class="col-md-6">
<!-- Додати учасника — action=add -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-person-plus me-2"></i>Додати учасника</h6>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger py-2 small mb-3">
<i class="bi bi-exclamation-circle me-1"></i>{{ error }}
</div>
{% endif %}
<form method="post" class="d-flex gap-2">
{% csrf_token %}
<input type="hidden" name="action" value="add">
<input type="text"
name="username"
class="form-control form-control-sm"
placeholder="username учасника"
required>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg"></i>
</button>
</form>
</div>
</div>
<!-- Чат групи -->
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi bi-chat-dots-fill text-success fs-4"></i>
<div>
<h6 class="mb-0">Груповий чат</h6>
<p class="text-muted small mb-0">WebSocket чат у реальному часі</p>
</div>
<a href="{% url 'notes_app:group_chat' group.pk %}"
class="btn btn-outline-success btn-sm ms-auto">
Відкрити чат
</a>
</div>
</div>
<!-- Небезпечна зона: покинути або видалити -->
<div class="card border-danger shadow-sm">
<div class="card-header border-danger">
<h6 class="text-danger mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>Небезпечна зона
</h6>
</div>
<div class="card-body">
<!-- Покинути групу — action=leave -->
<form method="post" class="d-inline">
{% csrf_token %}
<input type="hidden" name="action" value="leave">
<button type="submit"
class="btn btn-outline-danger btn-sm me-2"
onclick="return confirm('Покинути групу «{{ group.name }}»?')">
<i class="bi bi-door-open me-1"></i>Покинути групу
</button>
</form>
<!-- Видалити групу — окремий URL (GET → confirm page) -->
<a href="{% url 'notes_app:group_delete' group.pk %}"
class="btn btn-danger btn-sm">
<i class="bi bi-trash me-1"></i>Видалити групу
</a>
</div>
</div>
</div>
</div>
{% endblock %}
group_confirm_delete.html¶
{% extends 'layouts/dashboard.html' %}
{% block topbar_title %}Видалення групи{% endblock %}
{% block content %}
<div class="card border-danger shadow-sm" style="max-width: 500px;">
<div class="card-header bg-danger text-white">
<h6 class="mb-0">
<i class="bi bi-trash me-2"></i>Видалення групи «{{ group.name }}»
</h6>
</div>
<div class="card-body">
<div class="alert alert-warning">
<strong><i class="bi bi-exclamation-triangle me-1"></i>Після видалення:</strong>
<ul class="mb-0 mt-2 ps-3">
<li>Група та список учасників видаляться <strong>назавжди</strong>.</li>
<li>Нотатки і списки покупок <strong>НЕ видаляються</strong> —
вони стають особистими (<code>group = NULL</code>
через <code>on_delete=SET_NULL</code>).</li>
<li>Чат групи і всі повідомлення <strong>видаляться</strong>
(<code>ChatMessage.group FK CASCADE</code>).</li>
</ul>
</div>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger me-2">
<i class="bi bi-trash me-1"></i>Так, видалити
</button>
<a href="{% url 'notes_app:group_detail' group.pk %}"
class="btn btn-outline-secondary">
Скасувати
</a>
</form>
</div>
</div>
{% endblock %}
Примітка щодо
ChatMessage: уnotes_chat_appє щеChatMessage.group FK(Group, CASCADE). Тому при видаленні групи — чат і всі повідомлення видаляються (неSET_NULL). Нотатки і списки покупок захищені черезSET_NULL.
Далі¶
Наступна глава: Security Settings — таблиця security налаштувань, DEBUG=True витоки, HTTPS production settings.