Реализована полная система объявлений с уникальной бизнес-логикой

Основные возможности:
- Только 1 объявление на пользователя (database constraint)
- Автогенерация названия 'Объявления от {имя}'
- Только создатель может публиковать сообщения
- Админы могут блокировать/разблокировать объявления
- Заблокированные объявления скрыты от всех (кроме staff)
- Запрет на ответы (reply) в объявлениях (can_reply=False)
- Запрет на редактирование/удаление сообщений
- Уведомления всем участникам при новом объявлении

Детали реализации:
- Добавлены поля: is_blocked, blocked_at, blocked_by, can_reply
- API endpoints: /api/announcement/<id>/block/ и /unblock/
- Валидация на уровне views, API и WebSocket
- Новый тип уведомлений: announcement_new_message
- UI индикаторы блокировки и прав доступа
- Компактное отображение статуса 'только просмотр'

Затронуты файлы:
- models.py: новые поля и constraint
- api_views.py: валидация и API блокировки
- consumers.py: проверки прав в WebSocket
- views.py: фильтрация и контекст прав
- templates: UI блокировки и статусов
- notification_signals.py: уведомления для объявлений
This commit is contained in:
admin i 2025-11-30 12:18:09 +03:00
parent 4b6c25157d
commit 625c7bfebd
10 changed files with 462 additions and 19 deletions

View File

@ -70,12 +70,42 @@ def create_chat(request):
},
status=400
)
# Валидация для announcement - только 1 на пользователя
if chat_type == 'announcement':
existing = Chat.objects.filter(
type='announcement',
created_by=request.user
).first()
if existing:
if existing.is_blocked:
return JsonResponse(
{
'ok': False,
'error': 'Ваш чат объявлений заблокирован администратором'
},
status=403
)
else:
return JsonResponse(
{
'ok': False,
'error': 'У вас уже есть чат объявлений',
'existing_chat_id': existing.id
},
status=400
)
# Валидация названия
if not name and chat_type != 'private':
return JsonResponse(
{'ok': False, 'error': 'Name is required'}, status=400
)
# Для announcement генерируем название автоматически
if chat_type == 'announcement':
user_name = request.user.get_full_name() or request.user.username
name = f"Объявления от {user_name}"
else:
return JsonResponse(
{'ok': False, 'error': 'Name is required'}, status=400
)
# Валидация для чата отдела
if chat_type == 'department':
@ -131,15 +161,14 @@ def create_chat(request):
created_by=request.user,
department=department,
include_all_employees=include_all,
is_main=is_main
is_main=is_main,
can_reply=(chat_type != 'announcement') # Для announcement нельзя
)
# Добавляем аватар если был загружен
if avatar:
chat.avatar = avatar
chat.save()
# Создатель - владелец с полными правами
chat.save() # Создатель - владелец с полными правами
ChatMembership.objects.create(
chat=chat,
user=request.user,
@ -173,7 +202,14 @@ def create_chat(request):
id=request.user.id
)
for employee in all_employees:
can_send = chat_type not in ['channel', 'announcement']
# Для announcement и channel - обычные участники не могут писать
if chat_type == 'announcement':
can_send = False # Только создатель может писать
elif chat_type == 'channel':
can_send = False # Только модераторы могут писать
else:
can_send = True
ChatMembership.objects.get_or_create(
chat=chat,
user=employee,
@ -210,6 +246,65 @@ def create_chat(request):
})
@login_required
@require_POST
def block_announcement(request, chat_id):
"""
Блокирует announcement чат (только для staff/superuser)
После блокировки чат скрывается у всех пользователей
"""
if not (request.user.is_staff or request.user.is_superuser):
return JsonResponse(
{'ok': False, 'error': 'Permission denied'}, status=403
)
chat = get_object_or_404(Chat, pk=chat_id, type='announcement')
if chat.is_blocked:
return JsonResponse(
{'ok': False, 'error': 'Chat already blocked'}, status=400
)
chat.is_blocked = True
chat.blocked_at = timezone.now()
chat.blocked_by = request.user
chat.save()
return JsonResponse({
'ok': True,
'message': f'Announcement "{chat.name}" blocked'
})
@login_required
@require_POST
def unblock_announcement(request, chat_id):
"""
Разблокирует announcement чат (только для staff/superuser)
"""
if not (request.user.is_staff or request.user.is_superuser):
return JsonResponse(
{'ok': False, 'error': 'Permission denied'}, status=403
)
chat = get_object_or_404(Chat, pk=chat_id, type='announcement')
if not chat.is_blocked:
return JsonResponse(
{'ok': False, 'error': 'Chat is not blocked'}, status=400
)
chat.is_blocked = False
chat.blocked_at = None
chat.blocked_by = None
chat.save()
return JsonResponse({
'ok': True,
'message': f'Announcement "{chat.name}" unblocked'
})
@login_required
@require_POST
def update_chat(request, chat_id):

View File

@ -165,9 +165,36 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
if not text:
return
# Проверка на ответ
# Проверка прав на отправку сообщений
if self.chat.type == "announcement":
# Только создатель может писать в объявление
if self.chat.created_by_id != user.id:
await self.send_json({
"type": "error",
"error": "Только автор может публиковать в это объявление"
})
return
else:
# В других типах проверяем can_send_messages
has_permission = await self._check_send_permission(user)
if not has_permission:
await self.send_json({
"type": "error",
"error": "У вас нет прав для отправки сообщений"
})
return
# Проверка на ответ (reply)
reply_to_id = content.get("reply_to_id")
# Запрет на reply в чатах где can_reply=False
if reply_to_id and not self.chat.can_reply:
await self.send_json({
"type": "error",
"error": "В этом чате запрещены ответы на сообщения"
})
return
msg = await self._create_message(self.chat, user, text, reply_to_id)
await self._mark_read(self.chat, user)
@ -188,6 +215,14 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
if not message_id or not new_content:
return
# Запрет на редактирование в объявлениях
if self.chat.type == "announcement":
await self.send_json({
"type": "error",
"error": "Редактирование запрещено в объявлениях"
})
return
success = await self._edit_message(message_id, user, new_content)
if success:
msg = await self._get_message(message_id)
@ -208,6 +243,14 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
if not message_id:
return
# Запрет на удаление в объявлениях
if self.chat.type == "announcement":
await self.send_json({
"type": "error",
"error": "Удаление запрещено в объявлениях"
})
return
success = await self._delete_message(message_id, user)
if success:
await self.channel_layer.group_send(
@ -348,6 +391,17 @@ class ChatConsumer(AsyncJsonWebsocketConsumer):
.get(pk=chat_id)
)
@database_sync_to_async
def _check_send_permission(self, user) -> bool:
"""Проверка права на отправку сообщений"""
from .models import ChatMembership
membership = ChatMembership.objects.filter(
chat=self.chat, user=user
).first()
if membership:
return membership.can_send_messages
return True # По умолчанию можно писать
@database_sync_to_async
def _user_can_access(self, chat: Chat, user) -> bool:
if chat.type == "global":

View File

@ -0,0 +1,41 @@
# Generated by Django 5.2.4 on 2025-11-30 08:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0014_remove_messengermessage_sender_account_and_more'),
('employees', '0034_alter_employee_telegram'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='chat',
name='blocked_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Дата блокировки'),
),
migrations.AddField(
model_name='chat',
name='blocked_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocked_chats', to=settings.AUTH_USER_MODEL, verbose_name='Кто заблокировал'),
),
migrations.AddField(
model_name='chat',
name='can_reply',
field=models.BooleanField(default=True, help_text='False для announcement (только реакции)', verbose_name='Можно отвечать'),
),
migrations.AddField(
model_name='chat',
name='is_blocked',
field=models.BooleanField(default=False, help_text='Для announcement: заблокирован администратором, скрыт у всех', verbose_name='Заблокирован'),
),
migrations.AddConstraint(
model_name='chat',
constraint=models.UniqueConstraint(condition=models.Q(('type', 'announcement')), fields=('type', 'created_by'), name='unique_announcement_per_user'),
),
]

View File

@ -142,6 +142,31 @@ class Chat(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
is_main = models.BooleanField(default=False, verbose_name="Основной чат")
# Новые поля для announcement и channel
is_blocked = models.BooleanField(
default=False,
verbose_name="Заблокирован",
help_text="Для announcement: заблокирован администратором, скрыт у всех"
)
blocked_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата блокировки"
)
blocked_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='blocked_chats',
verbose_name="Кто заблокировал"
)
can_reply = models.BooleanField(
default=True,
verbose_name="Можно отвечать",
help_text="False для announcement (только реакции)"
)
class Meta:
verbose_name = "Чат"
@ -159,6 +184,12 @@ class Chat(models.Model):
condition=Q(is_main=True, type="department"),
name="unique_main_department_chat",
),
# Только 1 announcement на сотрудника
models.UniqueConstraint(
fields=["type", "created_by"],
condition=Q(type="announcement"),
name="unique_announcement_per_user",
),
]
indexes = [
models.Index(fields=["type", "is_main"]),

View File

@ -98,6 +98,7 @@ def create_message_notifications(sender, instance, created, **kwargs):
# Определяем, кому отправлять обычные уведомления
# Для приватных чатов - всегда отправляем
# Для announcement - отправляем ВСЕМ участникам (это важные объявления)
# Для групповых - можно добавить логику (например, только онлайн или с настройками)
if chat.type == 'private':
recipients_for_new_message = participants.exclude(id__in=excluded_ids)
@ -117,6 +118,26 @@ def create_message_notifications(sender, instance, created, **kwargs):
'author_id': author.id,
}
)
elif chat.type == 'announcement':
# Для объявлений отправляем ВСЕМ участникам
recipients_for_announcement = participants.exclude(id__in=excluded_ids)
for recipient in recipients_for_announcement:
NotificationService.create_notification(
recipient=recipient,
notification_type_code='announcement_new_message',
title=f'Новое объявление от {author.get_full_name() or author.username}',
message=truncate_message(content, 150),
content_object=instance,
action_url=f'/communications/chats/{chat.id}/',
metadata={
'chat_id': chat.id,
'chat_name': get_chat_name(chat),
'message_id': instance.id,
'author_id': author.id,
'is_announcement': True,
}
)
elif chat.type in ['group', 'department', 'channel']:
# Для групповых чатов - отправляем уведомление только если есть упоминание или ответ
# Обычные сообщения не создают уведомления, чтобы не спамить

View File

@ -40,6 +40,16 @@ urlpatterns = [
api_views.set_chat_notifications,
name="api_chat_notifications"
),
path(
"api/announcement/<int:chat_id>/block/",
api_views.block_announcement,
name="api_block_announcement"
),
path(
"api/announcement/<int:chat_id>/unblock/",
api_views.unblock_announcement,
name="api_unblock_announcement"
),
# Message actions
path(

View File

@ -140,8 +140,19 @@ class ChatListView(LoginRequiredMixin, ListView):
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids) # group/channel/announcement
)
.exclude(
# Скрываем заблокированные announcement
Q(type="announcement", is_blocked=True) &
~Q(created_by=user) # Создателю показываем
)
.exclude(
# Скрываем заблокированные announcement и для создателя
# если он не staff (чтобы staff мог разблокировать)
Q(type="announcement", is_blocked=True, created_by=user) &
Q(created_by__is_staff=False)
)
.distinct()
.select_related("department")
.select_related("department", "created_by", "blocked_by")
.annotate(last_msg_at=Subquery(last_qs))
.annotate(
last_read_at=Coalesce(
@ -331,37 +342,72 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
else:
context["unread_from_ts"] = None
# Права на отправку сообщений в объявлениях
if chat.type == "announcement":
# Только создатель может писать в объявление
context["can_send_messages"] = (chat.created_by == user)
context["is_announcement_creator"] = (chat.created_by == user)
else:
# В других типах чатов проверяем can_send_messages
membership = ChatMembership.objects.filter(
chat=chat, user=user
).first()
context["can_send_messages"] = (
membership.can_send_messages if membership
else True # По умолчанию можно писать
)
context["is_announcement_creator"] = False
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user = request.user
chat = self.object
if not ChatDetailView._user_has_access(self, self.object, user):
if not ChatDetailView._user_has_access(self, chat, user):
from django.http import HttpResponseForbidden
return HttpResponseForbidden("forbidden")
# Проверка прав на отправку сообщений
if chat.type == "announcement":
# Только создатель может писать в объявление
if chat.created_by != user:
from django.http import HttpResponseForbidden
return HttpResponseForbidden(
"Только автор может публиковать в это объявление"
)
else:
# В других типах чатов проверяем can_send_messages
membership = ChatMembership.objects.filter(
chat=chat, user=user
).first()
if membership and not membership.can_send_messages:
from django.http import HttpResponseForbidden
return HttpResponseForbidden(
"У вас нет прав для отправки сообщений"
)
form = self.get_form()
if form.is_valid():
msg = form.save(commit=False)
msg.chat = self.object
msg.chat = chat
msg.author = user
msg.save()
# автору сразу «прочитано» — делаем безопасно
ts = msg.created_at
updated = ChatReadState.objects.filter(
chat=self.object, user=user, last_read_at__lt=ts
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(
chat=self.object, user=user, last_read_at=ts
chat=chat, user=user, last_read_at=ts
)
except IntegrityError:
ChatReadState.objects.filter(
chat=self.object, user=user, last_read_at__lt=ts
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
return redirect(self.get_success_url())

View File

@ -65,6 +65,18 @@ class Command(BaseCommand):
},
'is_groupable': False,
},
{
'code': 'announcement_new_message',
'name': 'Новое объявление',
'description': 'Опубликовано новое объявление',
'priority': 'high',
'default_channels': {
'web': True,
'email': False,
'telegram': True
},
'is_groupable': False,
},
],
},
'documents': {

View File

@ -18,6 +18,19 @@
{% elif chat.type == 'department' %}
<i class="bi-diagram-3 fs-2 text-primary"></i>
<h2 class="title mb-0">Чат отдела: {{ chat.department.name }}</h2>
{% elif chat.type == 'announcement' %}
<i class="bi-megaphone fs-2 text-warning"></i>
<h2 class="title mb-0">{{ chat.name|default:"Объявление" }}</h2>
{% if chat.is_blocked %}
<span class="badge bg-danger ms-2">
<i class="bi-lock-fill"></i> Заблокировано
</span>
{% endif %}
{% if not is_announcement_creator %}
<span class="badge bg-info ms-2">
<i class="bi-eye"></i> Только просмотр
</span>
{% endif %}
{% else %}
<i class="bi-person fs-2 text-primary"></i>
<h2 class="title mb-0">
@ -31,7 +44,18 @@
{% endfor %}
</h2>
{% endif %}
<div class="ms-auto">
<div class="ms-auto d-flex gap-2">
{% if chat.type == 'announcement' and user.is_staff %}
{% if chat.is_blocked %}
<button type="button" class="btn btn-success btn-sm" onclick="unblockAnnouncement({{ chat.id }})">
<i class="bi-unlock"></i> Разблокировать
</button>
{% else %}
<button type="button" class="btn btn-danger btn-sm" onclick="blockAnnouncement({{ chat.id }})">
<i class="bi-lock"></i> Заблокировать
</button>
{% endif %}
{% endif %}
<a href="{% url 'communications:chat_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К чатам
</a>
@ -41,6 +65,35 @@
<!-- Корпус чата -->
<article class="card chat-box">
<!-- Предупреждение о блокировке -->
{% if chat.is_blocked %}
<div class="alert alert-danger m-3 mb-0" role="alert">
<div class="d-flex align-items-center">
<i class="bi-lock-fill fs-4 me-2"></i>
<div>
<strong>Это объявление заблокировано администратором</strong>
<br>
<small>
Заблокировано: {{ chat.blocked_at|date:"d.m.Y в H:i" }}
{% if chat.blocked_by %}
пользователем <strong>{{ chat.blocked_by.get_full_name|default:chat.blocked_by.username }}</strong>
{% endif %}
</small>
</div>
</div>
</div>
{% endif %}
<!-- Информация об объявлении -->
{% if chat.type == 'announcement' and not is_announcement_creator %}
<div class="alert alert-light border m-3 mb-0 py-2" role="alert">
<small class="text-secondary">
<i class="bi-info-circle me-1"></i>
Объявление от <strong>{{ chat.created_by.get_full_name|default:chat.created_by.username }}</strong>
</small>
</div>
{% endif %}
<!-- Кнопка «вниз» -->
<button id="scrollBtn" class="scroll-to-bottom" type="button" aria-label="Прокрутить вниз">
<i class="bi-arrow-down"></i>
@ -157,6 +210,7 @@
<!-- Композер -->
<div class="chat-composer">
{% if can_send_messages %}
<div id="typing" class="small text-secondary ps-1 pt-1 d-none">Собеседник печатает…</div>
<form id="chatForm"
class="composer-wrap"
@ -247,6 +301,18 @@
<!-- Превью прикрепленных файлов -->
<div id="attachmentPreview" class="attachment-preview d-none"></div>
</form>
{% else %}
<div class="text-center text-secondary py-3">
<i class="bi-lock fs-4"></i>
<p class="mb-0 mt-2">
{% if chat.type == 'announcement' %}
Только автор объявления может публиковать сообщения
{% else %}
У вас нет прав для отправки сообщений в этот чат
{% endif %}
</p>
</div>
{% endif %}
</div>
</article>
@ -261,4 +327,58 @@
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script type="module" src="{% static 'js/components/chatComposer.js' %}"></script>
<script type="module" src="{% static 'js/components/chatHistoryLoader.js' %}"></script>
<script>
// Блокировка объявления
async function blockAnnouncement(chatId) {
if (!confirm('Вы уверены, что хотите заблокировать это объявление? Оно исчезнет из списка чатов всех пользователей.')) {
return;
}
try {
const response = await fetch(`/communications/api/announcement/${chatId}/block/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
});
const data = await response.json();
if (response.ok) {
location.reload();
} else {
alert(data.error || 'Ошибка блокировки');
}
} catch (error) {
console.error('Error blocking announcement:', error);
alert('Ошибка при блокировке объявления');
}
}
// Разблокировка объявления
async function unblockAnnouncement(chatId) {
try {
const response = await fetch(`/communications/api/announcement/${chatId}/unblock/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
});
const data = await response.json();
if (response.ok) {
location.reload();
} else {
alert(data.error || 'Ошибка разблокировки');
}
} catch (error) {
console.error('Error unblocking announcement:', error);
alert('Ошибка при разблокировке объявления');
}
}
</script>
{% endblock %}

View File

@ -507,6 +507,11 @@
<a href="{% url 'communications:chat_detail' chat.pk %}" class="card-title text-decoration-none stretched-link">
{{ chat.name }}
</a>
{% if chat.is_blocked %}
<span class="badge bg-danger">
<i class="bi-lock-fill"></i> Заблокировано
</span>
{% endif %}
<span class="badge bg-light text-dark border">
<i class="bi-chat-left-text"></i> {{ chat.messages.count }}
</span>
@ -913,7 +918,15 @@
window.location.href = `/communications/chats/${data.chat_id}/`;
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'Неизвестная ошибка'));
// Если это ошибка с existing_chat_id - редиректим на существующий чат
if(err.existing_chat_id) {
if(confirm(err.error + '\n\nПерейти к вашему чату объявлений?')) {
window.location.href = `/communications/chats/${err.existing_chat_id}/`;
}
} else {
alert('Ошибка: ' + (err.error || 'Неизвестная ошибка'));
}
}
}
});