diff --git a/backend/communications/api_views.py b/backend/communications/api_views.py index e6397b61..d2f5ab36 100644 --- a/backend/communications/api_views.py +++ b/backend/communications/api_views.py @@ -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): diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index 1921f4bd..2b2eec2b 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -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": diff --git a/backend/communications/migrations/0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more.py b/backend/communications/migrations/0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more.py new file mode 100644 index 00000000..cfb50545 --- /dev/null +++ b/backend/communications/migrations/0015_chat_blocked_at_chat_blocked_by_chat_can_reply_and_more.py @@ -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'), + ), + ] diff --git a/backend/communications/models.py b/backend/communications/models.py index dac12dd5..3e0849b9 100644 --- a/backend/communications/models.py +++ b/backend/communications/models.py @@ -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"]), diff --git a/backend/communications/notification_signals.py b/backend/communications/notification_signals.py index 11fd4819..b303e8f4 100644 --- a/backend/communications/notification_signals.py +++ b/backend/communications/notification_signals.py @@ -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']: # Для групповых чатов - отправляем уведомление только если есть упоминание или ответ # Обычные сообщения не создают уведомления, чтобы не спамить diff --git a/backend/communications/urls.py b/backend/communications/urls.py index 6caffdc2..d0c2d6d3 100644 --- a/backend/communications/urls.py +++ b/backend/communications/urls.py @@ -40,6 +40,16 @@ urlpatterns = [ api_views.set_chat_notifications, name="api_chat_notifications" ), + path( + "api/announcement//block/", + api_views.block_announcement, + name="api_block_announcement" + ), + path( + "api/announcement//unblock/", + api_views.unblock_announcement, + name="api_unblock_announcement" + ), # Message actions path( diff --git a/backend/communications/views.py b/backend/communications/views.py index 639a5c01..59f9bf8f 100644 --- a/backend/communications/views.py +++ b/backend/communications/views.py @@ -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()) diff --git a/backend/notifications/management/commands/create_notification_types.py b/backend/notifications/management/commands/create_notification_types.py index c0f8c17f..3dad882c 100644 --- a/backend/notifications/management/commands/create_notification_types.py +++ b/backend/notifications/management/commands/create_notification_types.py @@ -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': { diff --git a/backend/templates/communications/chat_detail.html b/backend/templates/communications/chat_detail.html index 77cc423a..9b3b2c43 100644 --- a/backend/templates/communications/chat_detail.html +++ b/backend/templates/communications/chat_detail.html @@ -18,6 +18,19 @@ {% elif chat.type == 'department' %}

Чат отдела: {{ chat.department.name }}

+ {% elif chat.type == 'announcement' %} + +

{{ chat.name|default:"Объявление" }}

+ {% if chat.is_blocked %} + + Заблокировано + + {% endif %} + {% if not is_announcement_creator %} + + Только просмотр + + {% endif %} {% else %}

@@ -31,7 +44,18 @@ {% endfor %}

{% endif %} -
+
+ {% if chat.type == 'announcement' and user.is_staff %} + {% if chat.is_blocked %} + + {% else %} + + {% endif %} + {% endif %} К чатам @@ -41,6 +65,35 @@
+ + {% if chat.is_blocked %} + + {% endif %} + + + {% if chat.type == 'announcement' and not is_announcement_creator %} + + {% endif %} +
@@ -261,4 +327,58 @@ + + {% endblock %} diff --git a/backend/templates/communications/chat_list.html b/backend/templates/communications/chat_list.html index ce03c72c..22807e03 100644 --- a/backend/templates/communications/chat_list.html +++ b/backend/templates/communications/chat_list.html @@ -507,6 +507,11 @@ {{ chat.name }} + {% if chat.is_blocked %} + + Заблокировано + + {% endif %} {{ chat.messages.count }} @@ -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 || 'Неизвестная ошибка')); + } } } });